From a4d10341e720636e745692f3bcb91973230de817 Mon Sep 17 00:00:00 2001 From: jochen Date: Sat, 12 Sep 2026 16:57:18 +0200 Subject: [PATCH 01/21] Declare what hello-web is made of, to prove a build from a path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A proof branch, not for main: the route-forwarding bed reads this module's literal image and would break until the module is built. The modelling is right regardless — the image is upstream, so the mesh should mirror it once into its own registry and pin what that registry assigned, rather than every machine fetching a reference somebody else can move. Claude-Session: https://claude.ai/code/session_01D6qtiYU3P9jk3pnAXyAFyx --- modules/hello-web/module.json | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/modules/hello-web/module.json b/modules/hello-web/module.json index fb2dfc3..177124a 100644 --- a/modules/hello-web/module.json +++ b/modules/hello-web/module.json @@ -48,7 +48,6 @@ "id": "server", "type": "container", "name": "hello-web", - "image": "alpine@sha256:28bd5fe8b56d1bd048e5babf5b10710ebe0bae67db86916198a6eec434943f8b", "network": "hello-web", "ports": [ "8080:8080" @@ -60,7 +59,17 @@ "sh", "-c", "while true; do { printf 'HTTP/1.1 200 OK\\r\\nContent-Type: text/plain\\r\\nConnection: close\\r\\n\\r\\n'; cat /www/index.html; } | nc -l -p 8080; done" - ] + ], + "artifact": "server" } - ] + ], + "build": { + "artifacts": [ + { + "name": "server", + "kind": "upstream", + "from": "alpine@sha256:28bd5fe8b56d1bd048e5babf5b10710ebe0bae67db86916198a6eec434943f8b" + } + ] + } } -- 2.54.0 From a73cb8a2f82ff55c7a119a12518eae63343ec707 Mon Sep 17 00:00:00 2001 From: jochen Date: Sat, 12 Sep 2026 23:16:05 +0200 Subject: [PATCH 02/21] The catalogue, as a module that owns the module graph MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit It links module-versions to each other and knows nothing about nodes; which machine runs what stays the control plane's (novox/hq ADR 0070, 0072). Keeping them apart is what lets the control plane carry on composing declarations while this is down. The builder announces what it built, this places it in the graph and announces what that means, and the control plane hooks the meaning rather than the build output. A rebuild producing the commit already current is registered and is not an upgrade — announcing it would ripple outward forever through modules that did not change. Ordering is not computed. Modules stale and waiting on nothing that is itself stale are announced as buildable; the rest stay stale and appear once whatever they were waiting for is registered, so a chain and a diamond need no special handling and nothing holds a plan. Four tools over the graph: what this mesh holds, one module in full, what a change to a module reaches, and what must be rebuilt and why. The edges are derived from builds rather than declared, so they cannot drift from what the code actually uses. Claude-Session: https://claude.ai/code/session_01D6qtiYU3P9jk3pnAXyAFyx --- modules/mesh-catalog/index.ts | 79 ++++++++++ modules/mesh-catalog/module.json | 77 +++++++++ modules/mesh-catalog/package.json | 15 ++ modules/mesh-catalog/pg.d.ts | 22 +++ modules/mesh-catalog/store.ts | 236 ++++++++++++++++++++++++++++ modules/mesh-catalog/tools/index.ts | 67 ++++++++ modules/mesh-catalog/tsconfig.json | 17 ++ 7 files changed, 513 insertions(+) create mode 100644 modules/mesh-catalog/index.ts create mode 100644 modules/mesh-catalog/module.json create mode 100644 modules/mesh-catalog/package.json create mode 100644 modules/mesh-catalog/pg.d.ts create mode 100644 modules/mesh-catalog/store.ts create mode 100644 modules/mesh-catalog/tools/index.ts create mode 100644 modules/mesh-catalog/tsconfig.json diff --git a/modules/mesh-catalog/index.ts b/modules/mesh-catalog/index.ts new file mode 100644 index 0000000..7d7f29d --- /dev/null +++ b/modules/mesh-catalog/index.ts @@ -0,0 +1,79 @@ +// mesh-catalog's entrypoint — the module graph's consumer (novox/hq ADR 0070, ADR 0072). +// +// The builder announces what it built; this places it in the graph and announces what that means. +// The control plane hooks the *meaning* — a module was upgraded — rather than the build output, so +// it never has to interpret an artifact or ask this module anything. +// +// **Ordering is not computed here.** When a registration makes something else stale, the modules +// whose own dependencies are all current are announced as needing a rebuild; the rest stay stale +// and appear the next time round, once whatever they were waiting for is registered. A chain and a +// diamond need no special handling, and nothing holds a plan. + +import { on, emit } from "@novox/mesh-sdk/events"; +import { Graph, type BuiltAgainst } from "./store.js"; + +const graph = Graph.fromEnv(); + +// Before subscribing, and idempotent. The runtime is restarted until its store is reachable, which +// is the same arrangement model-usage uses: a schema step that had to reach the provider over the +// overlay would block the very apply that brings the overlay up. +await graph.migrate(); + +/** What the builder says when it has built something. */ +interface Built { + module?: string; + commit?: string; + repository?: string; + path?: string; + ref?: string; + manifest?: unknown; + /** Every artifact this was built against, so the edge is derived rather than declared. */ + against?: { module: string; commit: string }[]; +} + +await on("module.builder.built", async (event) => { + const body = event.body as Built; + if (!body.module || !body.commit) { + // Said rather than dropped: a build that announced itself without saying what it built is a + // fault in the builder, and a silent discard here would make it look like a missing event. + console.error("mesh-catalog: a build event named no module or no commit; ignored", body); + return; + } + + const against: BuiltAgainst[] = (body.against ?? []).map((a) => ({ + module: body.module as string, + commit: body.commit as string, + againstModule: a.module, + againstCommit: a.commit, + })); + + const { upgraded, previous } = await graph.register({ + module: body.module, + commit: body.commit, + repository: body.repository ?? "", + path: body.path ?? "", + ref: body.ref ?? "", + manifest: body.manifest ?? {}, + }, against); + + await emit("module.mesh-catalog.registered", { + module: body.module, commit: body.commit, upgraded, + }); + + // **A rebuild that changed nothing is not an upgrade.** Announcing it would ripple outward + // through modules that did not change, forever (ADR 0072). + if (!upgraded) return; + + await emit("module.mesh-catalog.upgraded", { + module: body.module, commit: body.commit, previous, + }); + + // What can be built now — stale, and waiting on nothing that is itself stale. + for (const next of await graph.buildable()) { + await emit("module.mesh-catalog.rebuild-needed", { + module: next.module, + builtAt: next.commit, + because: next.because, + }); + } +}); diff --git a/modules/mesh-catalog/module.json b/modules/mesh-catalog/module.json new file mode 100644 index 0000000..8ed36a3 --- /dev/null +++ b/modules/mesh-catalog/module.json @@ -0,0 +1,77 @@ +{ + "module": "mesh-catalog", + "slug": "catalog", + "version": "1", + "capabilities": [ + "container-runtime" + ], + "claims": [ + { + "name": "the-catalogue", + "scope": "mesh" + } + ], + "requires": [ + "postgres-database" + ], + "contributes": { + "postgres-database": { + "name": "mesh_catalog" + } + }, + "binds": { + "postgres-database": "/var/lib/mesh-catalog/database.json" + }, + "secrets": { + "postgres-database": "/var/lib/mesh-catalog/database.secret" + }, + "own-secrets": { + "broker": "/var/lib/mesh/mesh-catalog/broker" + }, + "consumes": [ + "module.builder.built" + ], + "emits": [ + "module.mesh-catalog.registered", + "module.mesh-catalog.upgraded", + "module.mesh-catalog.rebuild-needed" + ], + "resources": [ + { + "id": "mesh-state", + "type": "directory", + "path": "/var/lib/mesh/mesh-catalog", + "mode": "0700" + }, + { + "id": "state", + "type": "directory", + "path": "/var/lib/mesh-catalog", + "mode": "0700" + }, + { + "id": "db-env", + "type": "file", + "path": "/var/lib/mesh-catalog/db.env", + "mode": "0600", + "content": "DATABASE_URL=postgresql://${bound:postgres-database:as}:${secret:postgres-database}@${bound:postgres-database:at}:${bound:postgres-database:port}/${bound:postgres-database:as}\n" + }, + { + "id": "runtime", + "type": "container", + "name": "mesh-catalog", + "image": "mesh-runtime-mesh-catalog@sha256:0000000000000000000000000000000000000000000000000000000000000000", + "network": "host", + "volumes": [ + "/var/lib/mesh/mesh-catalog/broker:/run/secrets/broker:ro", + "/var/lib/mesh-catalog:/run/state" + ], + "env": { + "MESH_BROKER_FILE": "/run/secrets/broker" + }, + "env-file": [ + "/var/lib/mesh-catalog/db.env" + ] + } + ] +} diff --git a/modules/mesh-catalog/package.json b/modules/mesh-catalog/package.json new file mode 100644 index 0000000..3b60549 --- /dev/null +++ b/modules/mesh-catalog/package.json @@ -0,0 +1,15 @@ +{ + "name": "@novox/module-mesh-catalog", + "version": "0.1.0", + "description": "mesh-catalog — the module graph (novox/hq ADR 0070, 0072): links module-versions to each other, registers what the builder announces, and says what a change reaches and what can be rebuilt now. Knows nothing about nodes.", + "type": "module", + "private": true, + "dependencies": { + "@novox/mesh-sdk": "^0.1.0", + "pg": "^8" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "typescript": "^5.6.0" + } +} diff --git a/modules/mesh-catalog/pg.d.ts b/modules/mesh-catalog/pg.d.ts new file mode 100644 index 0000000..023b330 --- /dev/null +++ b/modules/mesh-catalog/pg.d.ts @@ -0,0 +1,22 @@ +// Ambient types for `pg` (node-postgres), which ships its types only via the separate `@types/pg` +// package. Rather than pull that in at tsc time, this declares the exact slice model-usage uses — +// the same precedent anthropic-manager sets for `tweetnacl-sealedbox-js` (a local ambient .d.ts, +// listed in tsconfig `include`, default-imported). The real `pg` is installed into the module's +// runtime image (package.json `dependencies`; novox/hq ADR 0052), so this types the code without +// deciding what runs. +declare module "pg" { + /** One checked-out connection. Needed because registering a module-version and its edges is one + * act: a half-written registration is a graph that lies about what something was built against. */ + export class PoolClient { + query(text: string, params?: unknown[]): Promise<{ rows: any[]; rowCount: number }>; + release(): void; + } + /** A lazily-connecting connection pool. Only the connection string, one query form, checking out + * a connection, and end() are used here. */ + export class Pool { + constructor(config?: { connectionString?: string }); + query(text: string, params?: unknown[]): Promise<{ rows: any[]; rowCount: number }>; + connect(): Promise; + end(): Promise; + } +} diff --git a/modules/mesh-catalog/store.ts b/modules/mesh-catalog/store.ts new file mode 100644 index 0000000..7863acd --- /dev/null +++ b/modules/mesh-catalog/store.ts @@ -0,0 +1,236 @@ +// The module graph (novox/hq ADR 0070, ADR 0072). +// +// **This graph links module-versions to each other and knows nothing about nodes.** Which machine +// runs what is the control plane's graph, and the two meet only when somebody installs something. +// Keeping them apart is what lets the control plane carry on composing declarations while this is +// down: it holds what it needs, and asks nothing here. +// +// A module-version is identified by its commit, not by a version string. A version is what somebody +// calls a release; a commit is what was actually built (ADR 0009). + +import pg from "pg"; + +// `pg` is CommonJS: its default export is the module object, so Pool is a property of it. This is +// the form that resolves under Node's ESM loader — the same shape model-usage uses. +const { Pool } = pg; +type PgPool = InstanceType; + +/** One module-version, as the builder delivered it. */ +export interface ModuleVersion { + module: string; + commit: string; + repository: string; + /** The module's directory inside that repository (novox/hq ADR 0069). Empty is the root. */ + path: string; + ref: string; + /** The manifest with its artifacts pinned — the module as the mesh should hold it. */ + manifest: unknown; +} + +/** An edge: this module-version was built against that one. Derived, never declared (ADR 0009). */ +export interface BuiltAgainst { + module: string; + commit: string; + againstModule: string; + againstCommit: string; +} + +/** A module whose artifacts were built against something that is no longer current. */ +export interface Stale { + module: string; + commit: string; + /** What moved underneath it, and to where. */ + because: { module: string; builtAgainst: string; nowAt: string }[]; +} + +const DDL = ` +CREATE TABLE IF NOT EXISTS module_version ( + module text NOT NULL, + commit_sha text NOT NULL, + repository text NOT NULL DEFAULT '', + path text NOT NULL DEFAULT '', + ref text NOT NULL DEFAULT '', + manifest jsonb NOT NULL DEFAULT '{}'::jsonb, + registered timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (module, commit_sha) +); + +-- What the catalogue considers this module to be now. Separate from module_version because a +-- module has many versions and exactly one current one, and "current" is a decision rather than +-- a consequence of being newest. +CREATE TABLE IF NOT EXISTS module_current ( + module text PRIMARY KEY, + commit_sha text NOT NULL, + moved timestamptz NOT NULL DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS built_against ( + module text NOT NULL, + commit_sha text NOT NULL, + against_module text NOT NULL, + against_commit text NOT NULL, + PRIMARY KEY (module, commit_sha, against_module, against_commit) +); + +CREATE INDEX IF NOT EXISTS built_against_target ON built_against (against_module); +`; + +export class Graph { + private constructor(private readonly pool: PgPool) {} + + static fromEnv(env: NodeJS.ProcessEnv = process.env): Graph { + const url = env["DATABASE_URL"]; + if (!url) { + throw new Error( + "no DATABASE_URL: the catalogue holds the module graph and cannot hold it in memory, " + + "because a graph that disappears on restart is not a record of anything", + ); + } + return new Graph(new Pool({ connectionString: url })); + } + + async migrate(): Promise { + await this.pool.query(DDL); + } + + /** + * Take a module-version the builder produced, and place it in the graph. + * + * **Returns whether this is an upgrade**, which is not the same as whether a build happened. A + * rebuild producing the commit already current changes nothing, and announcing it as an upgrade + * would ripple outward forever through modules that did not change (ADR 0072). + */ + async register(version: ModuleVersion, against: BuiltAgainst[]): Promise<{ upgraded: boolean; previous: string | null }> { + const client = await this.pool.connect(); + try { + await client.query("BEGIN"); + await client.query( + `INSERT INTO module_version (module, commit_sha, repository, path, ref, manifest) + VALUES ($1,$2,$3,$4,$5,$6) + ON CONFLICT (module, commit_sha) DO UPDATE SET + repository = excluded.repository, path = excluded.path, + ref = excluded.ref, manifest = excluded.manifest`, + [version.module, version.commit, version.repository, version.path, version.ref, + JSON.stringify(version.manifest ?? {})], + ); + + // The edges are replaced rather than added to: they describe this build, and a previous + // build of the same commit that saw different dependencies was wrong about one of them. + await client.query(`DELETE FROM built_against WHERE module = $1 AND commit_sha = $2`, + [version.module, version.commit]); + for (const e of against) { + await client.query( + `INSERT INTO built_against (module, commit_sha, against_module, against_commit) + VALUES ($1,$2,$3,$4) ON CONFLICT DO NOTHING`, + [version.module, version.commit, e.againstModule, e.againstCommit]); + } + + const was = await client.query( + `SELECT commit_sha FROM module_current WHERE module = $1`, [version.module]); + const previous = (was.rows[0]?.commit_sha as string | undefined) ?? null; + const upgraded = previous !== version.commit; + if (upgraded) { + await client.query( + `INSERT INTO module_current (module, commit_sha) VALUES ($1,$2) + ON CONFLICT (module) DO UPDATE SET commit_sha = excluded.commit_sha, moved = now()`, + [version.module, version.commit]); + } + await client.query("COMMIT"); + return { upgraded, previous }; + } catch (err) { + await this.pool.query("ROLLBACK").catch(() => {}); + throw err; + } finally { + client.release(); + } + } + + /** Every module the catalogue holds, with the commit it considers current. */ + async modules(): Promise<{ module: string; commit: string; repository: string; path: string }[]> { + const { rows } = await this.pool.query( + `SELECT c.module, c.commit_sha, v.repository, v.path + FROM module_current c + JOIN module_version v ON v.module = c.module AND v.commit_sha = c.commit_sha + ORDER BY c.module`); + return rows.map((r) => ({ + module: r.module as string, commit: r.commit_sha as string, + repository: r.repository as string, path: r.path as string, + })); + } + + /** One module-version in full, current unless a commit is named. */ + async show(module: string, commit?: string): Promise { + const { rows } = await this.pool.query( + commit + ? `SELECT * FROM module_version WHERE module = $1 AND commit_sha = $2` + : `SELECT v.* FROM module_version v JOIN module_current c + ON c.module = v.module AND c.commit_sha = v.commit_sha WHERE v.module = $1`, + commit ? [module, commit] : [module]); + const r = rows[0]; + if (!r) return null; + return { + module: r.module as string, commit: r.commit_sha as string, + repository: r.repository as string, path: r.path as string, + ref: r.ref as string, manifest: r.manifest, + }; + } + + /** What was built against this module — the modules a change to it reaches. */ + async dependents(module: string): Promise<{ module: string; commit: string; againstCommit: string }[]> { + const { rows } = await this.pool.query( + `SELECT b.module, b.commit_sha, b.against_commit + FROM built_against b + JOIN module_current c ON c.module = b.module AND c.commit_sha = b.commit_sha + WHERE b.against_module = $1 + ORDER BY b.module`, [module]); + return rows.map((r) => ({ + module: r.module as string, commit: r.commit_sha as string, + againstCommit: r.against_commit as string, + })); + } + + /** + * What must be rebuilt, and why. + * + * **The rule is a condition, not an order** (ADR 0072): a module is stale when anything it was + * built against is no longer current. Asking this repeatedly is what produces the right order — + * a module whose own dependencies are still stale simply stays stale until they are not, so a + * chain and a diamond need no special handling and nothing has to know the shape in advance. + */ + async stale(): Promise { + const { rows } = await this.pool.query( + `SELECT b.module, b.commit_sha, b.against_module, b.against_commit, c2.commit_sha AS now_at + FROM built_against b + JOIN module_current c1 ON c1.module = b.module AND c1.commit_sha = b.commit_sha + JOIN module_current c2 ON c2.module = b.against_module + WHERE c2.commit_sha <> b.against_commit + ORDER BY b.module`); + const by = new Map(); + for (const r of rows) { + const key = `${r.module}@${r.commit_sha}`; + const entry = by.get(key) ?? { module: r.module as string, commit: r.commit_sha as string, because: [] }; + entry.because.push({ + module: r.against_module as string, + builtAgainst: r.against_commit as string, + nowAt: r.now_at as string, + }); + by.set(key, entry); + } + return [...by.values()]; + } + + /** + * Modules that are stale and whose own dependencies are all current — the ones that can be built + * now. **This is the whole of ordering.** Anything not in this set is waiting for something else, + * and will appear here once that thing is registered. + */ + async buildable(): Promise { + const stale = await this.stale(); + const waiting = new Set(stale.map((s) => s.module)); + return stale.filter((s) => !s.because.some((b) => waiting.has(b.module))); + } + + async close(): Promise { + await this.pool.end(); + } +} diff --git a/modules/mesh-catalog/tools/index.ts b/modules/mesh-catalog/tools/index.ts new file mode 100644 index 0000000..09a1cba --- /dev/null +++ b/modules/mesh-catalog/tools/index.ts @@ -0,0 +1,67 @@ +// mesh-catalog's tools — the module graph's query surface (novox/hq ADR 0070). +// +// These are the questions the graph exists to answer, and none of them can be answered anywhere +// else today: what does this mesh know how to run, what is this module made of, what does a change +// to this reach, and what is waiting to be rebuilt. + +import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools"; +import { Graph } from "../store.js"; + +export function getCatalogueTools(graph: Graph): ToolDefinition[] { + return [ + { + name: "catalog_modules", + description: + "Every module this mesh holds, with the commit the catalogue considers current and where it came from.", + input: {}, + run: async () => ({ modules: await graph.modules() }), + }, + { + name: "catalog_module", + description: + "One module in full — where it came from, what it requires and provides, and what it is made of. The current version unless a commit is named.", + input: { + module: { type: "string", description: "the module's name" }, + commit: { type: "string", description: "a particular version (optional)" }, + }, + run: async (args) => { + const module = String(args.module ?? ""); + if (!module) return { error: "name a module" }; + const found = await graph.show(module, args.commit ? String(args.commit) : undefined); + return found ? { module: found } : { error: `the catalogue holds no ${module}` }; + }, + }, + { + name: "catalog_dependents", + description: + "What was built against this module — the modules a change to it reaches. Derived from builds, not from a declared list, so it cannot drift from what the code actually uses.", + input: { module: { type: "string", description: "the module that would change" } }, + run: async (args) => { + const module = String(args.module ?? ""); + if (!module) return { error: "name a module" }; + return { module, dependents: await graph.dependents(module) }; + }, + }, + { + name: "catalog_stale", + description: + "What must be rebuilt and why — every module built against something that has since moved. `buildable` is the subset waiting on nothing that is itself stale, which is the set that can be built right now.", + input: {}, + run: async () => ({ + stale: await graph.stale(), + buildable: await graph.buildable(), + }), + }, + ]; +} + +// Opened from the environment when the runtime asks for the module's tools. When it cannot be — +// no DATABASE_URL — the module contributes no tools rather than taking the whole tool runtime down +// with it, which is the postgres precedent. +registerModuleTools("mesh-catalog", (env) => { + try { + return getCatalogueTools(Graph.fromEnv(env)); + } catch { + return []; + } +}); diff --git a/modules/mesh-catalog/tsconfig.json b/modules/mesh-catalog/tsconfig.json new file mode 100644 index 0000000..2528f05 --- /dev/null +++ b/modules/mesh-catalog/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "noEmit": true + }, + "include": [ + "pg.d.ts", + "store.ts", + "index.ts", + "tools/index.ts" + ] +} -- 2.54.0 From 39631d6f8700a7690a5087cf0f83d7876fa9658e Mon Sep 17 00:00:00 2001 From: jochen Date: Sun, 13 Sep 2026 00:52:57 +0200 Subject: [PATCH 03/21] amqp-ping says what it is made of, and can be built from its own directory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A module's runtime image was assembled by a script copying the sdk and the tool runtime out of neighbouring checkouts, so it could only be built on a workstation that had them. That is why no module declared what it was made of and why forty-seven point at a placeholder. The tool runtime becomes an image a module's runtime is built FROM, published like any other artifact. The module then builds from its own directory and that base — one clone, which is what the builder can actually be asked for (novox/hq ADR 0069). The dependency stops being a property of somebody's machine and becomes a build edge, pinned to a digest the mesh's registry assigned. The compiler is invoked by its real path rather than through node_modules/.bin: those are symlinks to a launcher that requires its library relatively, and resolving them while building the base leaves a launcher pointing at nothing. Claude-Session: https://claude.ai/code/session_01D6qtiYU3P9jk3pnAXyAFyx --- modules/amqp-ping/Dockerfile | 26 ++++++++++++++++++++++++++ modules/amqp-ping/module.json | 15 ++++++++++++--- 2 files changed, 38 insertions(+), 3 deletions(-) create mode 100644 modules/amqp-ping/Dockerfile diff --git a/modules/amqp-ping/Dockerfile b/modules/amqp-ping/Dockerfile new file mode 100644 index 0000000..2f377a3 --- /dev/null +++ b/modules/amqp-ping/Dockerfile @@ -0,0 +1,26 @@ +# amqp-ping's runtime: the tool runtime, carrying this module's compiled code. +# +# **Built from this module's own directory and nothing else.** The sdk and the tool runtime are not +# copied out of neighbouring checkouts — they are in the base image, which is published like any +# other artifact. That is what makes this buildable by the mesh from a repository and a path +# (novox/hq ADR 0069) rather than only on a workstation that happens to have the siblings. +# +# The base is named by ARG so it can be pinned to a digest the mesh's registry assigned. A tag here +# would make the runtime's contents depend on what somebody last pushed under that name. +ARG RUNTIME_BASE=127.0.0.1:5000/mesh-tool-runtime@sha256:d4d793c828a5f563fdd8f49e5c6026d17b308a3f7a13c589849188362e4c7415 + +FROM ${RUNTIME_BASE} AS build +# Compiled under /app/modules, so resolving `@novox/mesh-sdk` walks up to the base's own +# node_modules — the module is compiled against exactly the sdk it will run against. +WORKDIR /app/modules/amqp-ping +COPY . . +# The compiler is invoked by its real path, not through node_modules/.bin. Those are symlinks to +# a launcher that requires its library relatively, and the base image resolves them when copying — +# leaving a launcher whose relative require no longer points at anything. +RUN node /app/node_modules/typescript/bin/tsc client.ts index.ts \ + --module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist + +FROM ${RUNTIME_BASE} +COPY --from=build /app/modules/amqp-ping/dist /app/modules/amqp-ping/dist +# Declared rather than derived from which files happen to exist: the module knows what it serves. +ENV MESH_TOOL_MODULES=/app/modules/amqp-ping/dist/index.js diff --git a/modules/amqp-ping/module.json b/modules/amqp-ping/module.json index ab4a0cb..4740226 100644 --- a/modules/amqp-ping/module.json +++ b/modules/amqp-ping/module.json @@ -47,7 +47,6 @@ "id": "runtime", "type": "container", "name": "amqp-ping", - "image": "mesh-runtime-amqp-ping@sha256:0000000000000000000000000000000000000000000000000000000000000000", "network": "amqp-ping", "env-file": [ "/var/lib/amqp-ping/amqp.env" @@ -58,7 +57,17 @@ ], "restart-on": [ "amqp-env" - ] + ], + "artifact": "runtime" } - ] + ], + "build": { + "artifacts": [ + { + "name": "runtime", + "kind": "image", + "from": "Dockerfile" + } + ] + } } -- 2.54.0 From 81a80c675c73864b6e0d62815da58bf726489965 Mon Sep 17 00:00:00 2001 From: jochen Date: Sun, 13 Sep 2026 00:57:41 +0200 Subject: [PATCH 04/21] The builder declares what it announces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Its account is scoped from what it emits and consumes, and it declared neither — which is why asking for a generic module account produced one that authenticated and could do nothing, with the refusal surfacing a layer away as a permissions error against a queue. Declaring the announcement is not documentation here. It is what the permission is derived from. Claude-Session: https://claude.ai/code/session_01D6qtiYU3P9jk3pnAXyAFyx --- modules/builder/module.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/modules/builder/module.json b/modules/builder/module.json index 8de43ea..c9542aa 100644 --- a/modules/builder/module.json +++ b/modules/builder/module.json @@ -13,6 +13,9 @@ "requires": [ "artifact-store" ], + "emits": [ + "module.builder.built" + ], "own-secrets": { "broker": "/var/lib/mesh/builder/broker" }, -- 2.54.0 From 2a6fed6f4fd16794f426ac6d7e018a52a8aa02b5 Mon Sep 17 00:00:00 2001 From: jochen Date: Sun, 13 Sep 2026 01:07:37 +0200 Subject: [PATCH 05/21] The builder is told the mesh's name for the machine it runs on --- modules/builder/module.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/builder/module.json b/modules/builder/module.json index c9542aa..d061ed7 100644 --- a/modules/builder/module.json +++ b/modules/builder/module.json @@ -37,7 +37,7 @@ "type": "file", "path": "/var/lib/mesh/builder/builder.env", "mode": "0600", - "content": "MESH_BROKER_FILE=/run/mesh/broker\nMESH_REGISTRY=127.0.0.1:${bound:artifact-store:port}\nMESH_WORKSPACE=/workspace\n" + "content": "MESH_BROKER_FILE=/run/mesh/broker\nMESH_NODE=${machine:name}\nMESH_REGISTRY=127.0.0.1:${bound:artifact-store:port}\nMESH_WORKSPACE=/workspace\n" }, { "id": "server", -- 2.54.0 From d6c9c8d66619f60713d1f22e0ef781020fe0f9a6 Mon Sep 17 00:00:00 2001 From: jochen Date: Sun, 13 Sep 2026 01:10:45 +0200 Subject: [PATCH 06/21] postgres builds its own runtime, like any other module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Its provisioner container named an image nobody could produce — a zero digest placeholder. It names an artifact instead, and the module says how to build it, so the mesh can make the database provider the catalogue needs. --- modules/postgres/Dockerfile | 28 ++++++++++++++++++++++++++++ modules/postgres/module.json | 19 ++++++++++++++++--- 2 files changed, 44 insertions(+), 3 deletions(-) create mode 100644 modules/postgres/Dockerfile diff --git a/modules/postgres/Dockerfile b/modules/postgres/Dockerfile new file mode 100644 index 0000000..8faa5d5 --- /dev/null +++ b/modules/postgres/Dockerfile @@ -0,0 +1,28 @@ +# postgres's runtime: the tool runtime, carrying this module's compiled provisioner, tools and +# event consumer. +# +# **Built from this module's own directory and nothing else.** The sdk is in the base image, so +# nothing is copied out of a neighbouring checkout — which is what lets the mesh build this from a +# repository and a path (novox/hq ADR 0069) rather than only on a workstation that happens to have +# the siblings. +# +# The base is named by ARG so it can be pinned to a digest the mesh's registry assigned. A tag would +# make this runtime's contents depend on what somebody last pushed under that name. +ARG RUNTIME_BASE=127.0.0.1:5000/mesh-tool-runtime@sha256:d4d793c828a5f563fdd8f49e5c6026d17b308a3f7a13c589849188362e4c7415 + +FROM ${RUNTIME_BASE} AS build +# Compiled under /app/modules so `@novox/mesh-sdk` resolves upward into the base's own +# node_modules — the module is compiled against exactly the sdk it will run against. +WORKDIR /app/modules/postgres +COPY . . +# The compiler is invoked by its real path rather than through node_modules/.bin, whose entries are +# symlinks to a launcher that requires its library relatively — resolved away when the base image +# was assembled. +RUN node /app/node_modules/typescript/bin/tsc client.ts index.ts provisioner/index.ts tools/index.ts \ + --module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist + +FROM ${RUNTIME_BASE} +COPY --from=build /app/modules/postgres/dist /app/modules/postgres/dist +# Which of the three the container runs is the declaration's business, said in its `args` — one +# image, because they are one module and share a client. +ENV MESH_TOOL_MODULES=/app/modules/postgres/dist/index.js diff --git a/modules/postgres/module.json b/modules/postgres/module.json index e6d9089..a161300 100644 --- a/modules/postgres/module.json +++ b/modules/postgres/module.json @@ -102,7 +102,6 @@ "id": "runtime", "type": "container", "name": "mesh-postgres", - "image": "mesh-runtime-postgres@sha256:0000000000000000000000000000000000000000000000000000000000000000", "network": "postgres", "volumes": [ "/var/lib/mesh/postgres/broker:/run/secrets/broker:ro", @@ -114,7 +113,21 @@ "MESH_PROVISION_PASSWORD_FILE": "/run/secrets/superuser", "MESH_BROKER_FILE": "/run/secrets/broker", "MESH_RECEIVES": "/var/lib/postgres/grants/mesh.json" - } + }, + "artifact": "runtime", + "args": [ + "run", + "/app/modules/postgres/dist/provisioner/index.js" + ] } - ] + ], + "build": { + "artifacts": [ + { + "name": "runtime", + "kind": "image", + "from": "Dockerfile" + } + ] + } } -- 2.54.0 From e742b6a569c511ce882d61b7ecfab0eecb427997 Mon Sep 17 00:00:00 2001 From: jochen Date: Sun, 13 Sep 2026 01:13:43 +0200 Subject: [PATCH 07/21] The catalogue builds its own runtime, and postgres serves its tools Both modules keep their tools in an entrypoint of their own, so an image that named only the consumer would serve none of them. --- modules/mesh-catalog/Dockerfile | 28 ++++++++++++++++++++++++++++ modules/mesh-catalog/module.json | 17 +++++++++++++++-- modules/postgres/Dockerfile | 8 +++++--- 3 files changed, 48 insertions(+), 5 deletions(-) create mode 100644 modules/mesh-catalog/Dockerfile diff --git a/modules/mesh-catalog/Dockerfile b/modules/mesh-catalog/Dockerfile new file mode 100644 index 0000000..ffa7d71 --- /dev/null +++ b/modules/mesh-catalog/Dockerfile @@ -0,0 +1,28 @@ +# mesh-catalog's runtime: the tool runtime, carrying the catalogue's compiled graph, its consumer +# of what the builder announces, and its tools. +# +# **Built from this module's own directory and nothing else.** The sdk is in the base image, so +# nothing is copied out of a neighbouring checkout — which is what lets the mesh build this from a +# repository and a path (novox/hq ADR 0069) rather than only on a workstation with the siblings. +# +# The base is named by ARG so it can be pinned to a digest the mesh's registry assigned. A tag would +# make this runtime's contents depend on what somebody last pushed under that name. +ARG RUNTIME_BASE=127.0.0.1:5000/mesh-tool-runtime@sha256:d4d793c828a5f563fdd8f49e5c6026d17b308a3f7a13c589849188362e4c7415 + +FROM ${RUNTIME_BASE} AS build +# Compiled under /app/modules so `@novox/mesh-sdk` resolves upward into the base's own +# node_modules — the module is compiled against exactly the sdk it will run against. +WORKDIR /app/modules/mesh-catalog +COPY . . +# The compiler is invoked by its real path rather than through node_modules/.bin, whose entries are +# symlinks to a launcher that requires its library relatively — resolved away when the base image +# was assembled. +RUN node /app/node_modules/typescript/bin/tsc pg.d.ts store.ts index.ts tools/index.ts \ + --module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist + +FROM ${RUNTIME_BASE} +COPY --from=build /app/modules/mesh-catalog/dist /app/modules/mesh-catalog/dist +# What a tool host should load: the catalogue's tools. Its consumer of `module.builder.built` is the +# other entrypoint, and is what this module's own container runs — named in the declaration's `args` +# rather than here, because which one runs is the declaration's business. +ENV MESH_TOOL_MODULES=/app/modules/mesh-catalog/dist/tools/index.js diff --git a/modules/mesh-catalog/module.json b/modules/mesh-catalog/module.json index 8ed36a3..364232f 100644 --- a/modules/mesh-catalog/module.json +++ b/modules/mesh-catalog/module.json @@ -60,7 +60,6 @@ "id": "runtime", "type": "container", "name": "mesh-catalog", - "image": "mesh-runtime-mesh-catalog@sha256:0000000000000000000000000000000000000000000000000000000000000000", "network": "host", "volumes": [ "/var/lib/mesh/mesh-catalog/broker:/run/secrets/broker:ro", @@ -71,7 +70,21 @@ }, "env-file": [ "/var/lib/mesh-catalog/db.env" + ], + "artifact": "runtime", + "args": [ + "run", + "/app/modules/mesh-catalog/dist/index.js" ] } - ] + ], + "build": { + "artifacts": [ + { + "name": "runtime", + "kind": "image", + "from": "Dockerfile" + } + ] + } } diff --git a/modules/postgres/Dockerfile b/modules/postgres/Dockerfile index 8faa5d5..9371d75 100644 --- a/modules/postgres/Dockerfile +++ b/modules/postgres/Dockerfile @@ -23,6 +23,8 @@ RUN node /app/node_modules/typescript/bin/tsc client.ts index.ts provisioner/ind FROM ${RUNTIME_BASE} COPY --from=build /app/modules/postgres/dist /app/modules/postgres/dist -# Which of the three the container runs is the declaration's business, said in its `args` — one -# image, because they are one module and share a client. -ENV MESH_TOOL_MODULES=/app/modules/postgres/dist/index.js +# What a tool host should load from this module: its event consumer and its tools, which are +# separate entrypoints because they are loaded by different things. The provisioner is the third, +# and is not listed here — the declaration names it in the container's `args`, because it is what +# this module's own container runs. One image, because they are one module and share a client. +ENV MESH_TOOL_MODULES=/app/modules/postgres/dist/index.js,/app/modules/postgres/dist/tools/index.js -- 2.54.0 From f7d57e95566dc34ce4272cae5326cd9bd89ee777 Mon Sep 17 00:00:00 2001 From: jochen Date: Sun, 13 Sep 2026 01:16:23 +0200 Subject: [PATCH 08/21] The catalogue brings its own postgres driver The runtime base carries what every module needs, and a database driver is not that. Installed into an empty directory because the module's package.json also names the sdk, which lives in the base rather than on a registry. --- modules/mesh-catalog/Dockerfile | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/modules/mesh-catalog/Dockerfile b/modules/mesh-catalog/Dockerfile index ffa7d71..f0104d2 100644 --- a/modules/mesh-catalog/Dockerfile +++ b/modules/mesh-catalog/Dockerfile @@ -20,8 +20,22 @@ COPY . . RUN node /app/node_modules/typescript/bin/tsc pg.d.ts store.ts index.ts tools/index.ts \ --module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist +# **A module may need something the base image does not carry.** The base holds what every module +# needs — the sdk, the broker client — and a postgres driver is not that: the one other module that +# reaches a database shells out to psql instead. So the catalogue brings its own. +# +# Installed into an empty directory rather than into the module's, because the module's package.json +# also names `@novox/mesh-sdk`, which is not on any registry — it is in the base image. Asking npm to +# resolve this module's dependencies would therefore fail on the one it already has. +RUN mkdir -p /deps && cd /deps && \ + npm install --omit=dev --no-audit --no-fund --no-package-lock pg@8 + FROM ${RUNTIME_BASE} COPY --from=build /app/modules/mesh-catalog/dist /app/modules/mesh-catalog/dist +# Beside the compiled code, so `pg` resolves from it while `@novox/mesh-sdk` keeps walking up to the +# base image's own node_modules — the module gets its extra dependency without shadowing the sdk it +# was compiled against. +COPY --from=build /deps/node_modules /app/modules/mesh-catalog/node_modules # What a tool host should load: the catalogue's tools. Its consumer of `module.builder.built` is the # other entrypoint, and is what this module's own container runs — named in the declaration's `args` # rather than here, because which one runs is the declaration's business. -- 2.54.0 From 594295f009fafe9899d1e8811c381129f49cc88a Mon Sep 17 00:00:00 2001 From: jochen Date: Sun, 13 Sep 2026 01:22:38 +0200 Subject: [PATCH 09/21] The catalogue restarts when its database credentials change Without it the container keeps whatever the env file said when it was created. Nothing reports that: it runs, and it is wrong. --- modules/mesh-catalog/module.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/modules/mesh-catalog/module.json b/modules/mesh-catalog/module.json index 364232f..130b9e1 100644 --- a/modules/mesh-catalog/module.json +++ b/modules/mesh-catalog/module.json @@ -75,6 +75,9 @@ "args": [ "run", "/app/modules/mesh-catalog/dist/index.js" + ], + "restart-on": [ + "db-env" ] } ], -- 2.54.0 From 6ebf51d3126501c4eac958e529c5a84a7e43eb1e Mon Sep 17 00:00:00 2001 From: jochen Date: Sun, 13 Sep 2026 01:26:23 +0200 Subject: [PATCH 10/21] postgres's runtime carries the client it provisions through Its provisioner runs DDL by shelling out to psql, which the runtime base has no reason to hold. Every create failed with ENOENT and retried for ever. --- modules/postgres/Dockerfile | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/modules/postgres/Dockerfile b/modules/postgres/Dockerfile index 9371d75..a0fc42b 100644 --- a/modules/postgres/Dockerfile +++ b/modules/postgres/Dockerfile @@ -22,6 +22,13 @@ RUN node /app/node_modules/typescript/bin/tsc client.ts index.ts provisioner/ind --module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist FROM ${RUNTIME_BASE} +# **This module talks to its database through psql, so psql has to be here.** The client is how +# postgres's provisioner runs DDL — it does not carry a driver — and the runtime base holds only +# what every module needs. Root to install it, then back to the base's unprivileged user: a +# provisioner holding the superuser password has no business also being root in its container. +USER root +RUN apk add --no-cache postgresql-client +USER node COPY --from=build /app/modules/postgres/dist /app/modules/postgres/dist # What a tool host should load from this module: its event consumer and its tools, which are # separate entrypoints because they are loaded by different things. The provisioner is the third, -- 2.54.0 From c774d5dbe02e057fade2fb065d4264738cbb9bfa Mon Sep 17 00:00:00 2001 From: jochen Date: Sun, 13 Sep 2026 01:28:15 +0200 Subject: [PATCH 11/21] Install the postgres client the way the runtime base can The published base is debian; apk is not there and the build said so. --- modules/postgres/Dockerfile | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/modules/postgres/Dockerfile b/modules/postgres/Dockerfile index a0fc42b..d502d08 100644 --- a/modules/postgres/Dockerfile +++ b/modules/postgres/Dockerfile @@ -24,11 +24,10 @@ RUN node /app/node_modules/typescript/bin/tsc client.ts index.ts provisioner/ind FROM ${RUNTIME_BASE} # **This module talks to its database through psql, so psql has to be here.** The client is how # postgres's provisioner runs DDL — it does not carry a driver — and the runtime base holds only -# what every module needs. Root to install it, then back to the base's unprivileged user: a -# provisioner holding the superuser password has no business also being root in its container. -USER root -RUN apk add --no-cache postgresql-client -USER node +# what every module needs. +RUN apt-get update \ + && apt-get install -y --no-install-recommends postgresql-client \ + && rm -rf /var/lib/apt/lists/* COPY --from=build /app/modules/postgres/dist /app/modules/postgres/dist # What a tool host should load from this module: its event consumer and its tools, which are # separate entrypoints because they are loaded by different things. The provisioner is the third, -- 2.54.0 From 9387f8b0409f147ff3873de5e98a2dff71b96c1a Mon Sep 17 00:00:00 2001 From: jochen Date: Sun, 13 Sep 2026 01:30:39 +0200 Subject: [PATCH 12/21] A module that listens is served, not run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `run` imports an entrypoint without binding a broker — it exists for a step that works offline and exits. Both the catalogue and amqp-ping subscribe on import, so both died on the first on() with no broker bound. --- modules/amqp-ping/module.json | 4 ---- modules/mesh-catalog/Dockerfile | 11 +++++++---- modules/mesh-catalog/module.json | 4 ---- 3 files changed, 7 insertions(+), 12 deletions(-) diff --git a/modules/amqp-ping/module.json b/modules/amqp-ping/module.json index 4740226..5e7184c 100644 --- a/modules/amqp-ping/module.json +++ b/modules/amqp-ping/module.json @@ -51,10 +51,6 @@ "env-file": [ "/var/lib/amqp-ping/amqp.env" ], - "args": [ - "run", - "/app/modules/amqp-ping/dist/index.js" - ], "restart-on": [ "amqp-env" ], diff --git a/modules/mesh-catalog/Dockerfile b/modules/mesh-catalog/Dockerfile index f0104d2..3fb4317 100644 --- a/modules/mesh-catalog/Dockerfile +++ b/modules/mesh-catalog/Dockerfile @@ -36,7 +36,10 @@ COPY --from=build /app/modules/mesh-catalog/dist /app/modules/mesh-catalog/dist # base image's own node_modules — the module gets its extra dependency without shadowing the sdk it # was compiled against. COPY --from=build /deps/node_modules /app/modules/mesh-catalog/node_modules -# What a tool host should load: the catalogue's tools. Its consumer of `module.builder.built` is the -# other entrypoint, and is what this module's own container runs — named in the declaration's `args` -# rather than here, because which one runs is the declaration's business. -ENV MESH_TOOL_MODULES=/app/modules/mesh-catalog/dist/tools/index.js +# Both entrypoints, loaded in serve mode. +# +# **A consumer cannot be started with `run`.** That mode imports an entrypoint without binding a +# broker — it is for a step that does its work offline and exits — and the catalogue's whole job is +# to listen for what the builder announces. Serve binds the broker first, then imports these, so +# `on()` has something to subscribe to. +ENV MESH_TOOL_MODULES=/app/modules/mesh-catalog/dist/index.js,/app/modules/mesh-catalog/dist/tools/index.js diff --git a/modules/mesh-catalog/module.json b/modules/mesh-catalog/module.json index 130b9e1..6165f8b 100644 --- a/modules/mesh-catalog/module.json +++ b/modules/mesh-catalog/module.json @@ -72,10 +72,6 @@ "/var/lib/mesh-catalog/db.env" ], "artifact": "runtime", - "args": [ - "run", - "/app/modules/mesh-catalog/dist/index.js" - ], "restart-on": [ "db-env" ] -- 2.54.0 From f15c814145b9a291838fac8cbb170f482fb0a229 Mon Sep 17 00:00:00 2001 From: jochen Date: Sun, 13 Sep 2026 01:37:51 +0200 Subject: [PATCH 13/21] A build edge names an artifact, because that is what the builder can see MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The catalogue expected each edge to name a module and a commit. The builder sends a pinned image reference — it cannot know which module produced it, that is a fact about the graph. So every build that had been built on top of anything was rejected, and only the modules built against nothing ever registered. Versions now record what they published, and an edge resolves through that. An edge to an artifact no module here produced is kept: it resolves by itself when that module is registered, which is the ordinary case while a mesh fills in. --- modules/mesh-catalog/index.ts | 22 +++---- modules/mesh-catalog/store.ts | 106 +++++++++++++++++++++++++--------- 2 files changed, 90 insertions(+), 38 deletions(-) diff --git a/modules/mesh-catalog/index.ts b/modules/mesh-catalog/index.ts index 7d7f29d..20c64f0 100644 --- a/modules/mesh-catalog/index.ts +++ b/modules/mesh-catalog/index.ts @@ -10,7 +10,7 @@ // diamond need no special handling, and nothing holds a plan. import { on, emit } from "@novox/mesh-sdk/events"; -import { Graph, type BuiltAgainst } from "./store.js"; +import { Graph, type Made } from "./store.js"; const graph = Graph.fromEnv(); @@ -27,8 +27,15 @@ interface Built { path?: string; ref?: string; manifest?: unknown; - /** Every artifact this was built against, so the edge is derived rather than declared. */ - against?: { module: string; commit: string }[]; + /** What this build published, each pinned as anything else would name it. */ + made?: Made[]; + /** + * Every artifact this was built on top of, so the edge is derived rather than declared. + * + * References, not module names: the builder sees a pinned image and cannot see which module + * produced it. Turning that into an edge between module-versions is this module's job. + */ + against?: string[]; } await on("module.builder.built", async (event) => { @@ -40,13 +47,6 @@ await on("module.builder.built", async (event) => { return; } - const against: BuiltAgainst[] = (body.against ?? []).map((a) => ({ - module: body.module as string, - commit: body.commit as string, - againstModule: a.module, - againstCommit: a.commit, - })); - const { upgraded, previous } = await graph.register({ module: body.module, commit: body.commit, @@ -54,7 +54,7 @@ await on("module.builder.built", async (event) => { path: body.path ?? "", ref: body.ref ?? "", manifest: body.manifest ?? {}, - }, against); + }, body.made ?? [], body.against ?? []); await emit("module.mesh-catalog.registered", { module: body.module, commit: body.commit, upgraded, diff --git a/modules/mesh-catalog/store.ts b/modules/mesh-catalog/store.ts index 7863acd..b929204 100644 --- a/modules/mesh-catalog/store.ts +++ b/modules/mesh-catalog/store.ts @@ -27,15 +27,25 @@ export interface ModuleVersion { manifest: unknown; } -/** An edge: this module-version was built against that one. Derived, never declared (ADR 0009). */ -export interface BuiltAgainst { - module: string; - commit: string; - againstModule: string; - againstCommit: string; +/** An artifact a module-version produced, as the builder published it. */ +export interface Made { + name: string; + kind: string; + /** How anything else names it — for an image, a registry reference pinned to a digest. */ + reference: string; } -/** A module whose artifacts were built against something that is no longer current. */ +/** + * A module whose artifacts were built against something that is no longer current. + * + * **An edge is an artifact reference, not a module name.** The builder can see exactly what it + * built on top of — a pinned image — and cannot see which module produced it: that is a fact about + * the graph, and the graph is here. So the reference is what is stored, and resolving it to a + * module-version is a join against what each version says it made. An edge to an artifact no + * module here produced is kept rather than dropped; it resolves by itself the day that module is + * registered, which is the ordinary case while a mesh is still being filled in. + */ + export interface Stale { module: string; commit: string; @@ -64,15 +74,39 @@ CREATE TABLE IF NOT EXISTS module_current ( moved timestamptz NOT NULL DEFAULT now() ); -CREATE TABLE IF NOT EXISTS built_against ( - module text NOT NULL, - commit_sha text NOT NULL, - against_module text NOT NULL, - against_commit text NOT NULL, - PRIMARY KEY (module, commit_sha, against_module, against_commit) +-- What a module-version published. This is what makes a build edge resolvable: an edge names an +-- artifact, and this says which version put that artifact there. +CREATE TABLE IF NOT EXISTS module_artifact ( + module text NOT NULL, + commit_sha text NOT NULL, + name text NOT NULL, + kind text NOT NULL DEFAULT '', + reference text NOT NULL, + PRIMARY KEY (module, commit_sha, name) ); -CREATE INDEX IF NOT EXISTS built_against_target ON built_against (against_module); +CREATE INDEX IF NOT EXISTS module_artifact_reference ON module_artifact (reference); + +CREATE TABLE IF NOT EXISTS built_against ( + module text NOT NULL, + commit_sha text NOT NULL, + against_reference text NOT NULL, + PRIMARY KEY (module, commit_sha, against_reference) +); + +CREATE INDEX IF NOT EXISTS built_against_target ON built_against (against_reference); +`; + +// The graph first keyed its edges on a module and a commit, which the builder does not know and +// never sent — so every build that had been built against anything was rejected, and the only +// versions that registered were the ones built against nothing. No edge was ever stored, so there +// is nothing to carry across: the old columns are dropped and the new one added. +const MIGRATE = ` +ALTER TABLE built_against ADD COLUMN IF NOT EXISTS against_reference text; +DELETE FROM built_against WHERE against_reference IS NULL; +ALTER TABLE built_against DROP COLUMN IF EXISTS against_module; +ALTER TABLE built_against DROP COLUMN IF EXISTS against_commit; +ALTER TABLE built_against ALTER COLUMN against_reference SET NOT NULL; `; export class Graph { @@ -91,6 +125,7 @@ export class Graph { async migrate(): Promise { await this.pool.query(DDL); + await this.pool.query(MIGRATE); } /** @@ -100,7 +135,7 @@ export class Graph { * rebuild producing the commit already current changes nothing, and announcing it as an upgrade * would ripple outward forever through modules that did not change (ADR 0072). */ - async register(version: ModuleVersion, against: BuiltAgainst[]): Promise<{ upgraded: boolean; previous: string | null }> { + async register(version: ModuleVersion, made: Made[], against: string[]): Promise<{ upgraded: boolean; previous: string | null }> { const client = await this.pool.connect(); try { await client.query("BEGIN"); @@ -114,15 +149,25 @@ export class Graph { JSON.stringify(version.manifest ?? {})], ); - // The edges are replaced rather than added to: they describe this build, and a previous - // build of the same commit that saw different dependencies was wrong about one of them. + // Both are replaced rather than added to: they describe this build, and a previous build of + // the same commit that saw different artifacts was wrong about one of them. + await client.query(`DELETE FROM module_artifact WHERE module = $1 AND commit_sha = $2`, + [version.module, version.commit]); + for (const a of made) { + await client.query( + `INSERT INTO module_artifact (module, commit_sha, name, kind, reference) + VALUES ($1,$2,$3,$4,$5) ON CONFLICT (module, commit_sha, name) DO UPDATE SET + kind = excluded.kind, reference = excluded.reference`, + [version.module, version.commit, a.name, a.kind, a.reference]); + } + await client.query(`DELETE FROM built_against WHERE module = $1 AND commit_sha = $2`, [version.module, version.commit]); - for (const e of against) { + for (const reference of against) { await client.query( - `INSERT INTO built_against (module, commit_sha, against_module, against_commit) - VALUES ($1,$2,$3,$4) ON CONFLICT DO NOTHING`, - [version.module, version.commit, e.againstModule, e.againstCommit]); + `INSERT INTO built_against (module, commit_sha, against_reference) + VALUES ($1,$2,$3) ON CONFLICT DO NOTHING`, + [version.module, version.commit, reference]); } const was = await client.query( @@ -175,13 +220,18 @@ export class Graph { }; } - /** What was built against this module — the modules a change to it reaches. */ + /** + * What was built against this module — the modules a change to it reaches. + * + * Resolved through the artifacts each version published, because that is what an edge names. + */ async dependents(module: string): Promise<{ module: string; commit: string; againstCommit: string }[]> { const { rows } = await this.pool.query( - `SELECT b.module, b.commit_sha, b.against_commit + `SELECT b.module, b.commit_sha, a.commit_sha AS against_commit FROM built_against b JOIN module_current c ON c.module = b.module AND c.commit_sha = b.commit_sha - WHERE b.against_module = $1 + JOIN module_artifact a ON a.reference = b.against_reference + WHERE a.module = $1 ORDER BY b.module`, [module]); return rows.map((r) => ({ module: r.module as string, commit: r.commit_sha as string, @@ -199,11 +249,13 @@ export class Graph { */ async stale(): Promise { const { rows } = await this.pool.query( - `SELECT b.module, b.commit_sha, b.against_module, b.against_commit, c2.commit_sha AS now_at + `SELECT b.module, b.commit_sha, a.module AS against_module, a.commit_sha AS against_commit, + c2.commit_sha AS now_at FROM built_against b JOIN module_current c1 ON c1.module = b.module AND c1.commit_sha = b.commit_sha - JOIN module_current c2 ON c2.module = b.against_module - WHERE c2.commit_sha <> b.against_commit + JOIN module_artifact a ON a.reference = b.against_reference + JOIN module_current c2 ON c2.module = a.module + WHERE c2.commit_sha <> a.commit_sha ORDER BY b.module`); const by = new Map(); for (const r of rows) { -- 2.54.0 From 2d2ca80e3b0692ffc044d9b877739e22cc746a62 Mon Sep 17 00:00:00 2001 From: jochen Date: Sun, 13 Sep 2026 01:41:23 +0200 Subject: [PATCH 14/21] Index the edge after the column that carries it exists On a store with the old shape the table is not re-created, so an index declared beside it is built on a column the migration has not added yet. --- modules/mesh-catalog/store.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/modules/mesh-catalog/store.ts b/modules/mesh-catalog/store.ts index b929204..0e6aa58 100644 --- a/modules/mesh-catalog/store.ts +++ b/modules/mesh-catalog/store.ts @@ -93,8 +93,6 @@ CREATE TABLE IF NOT EXISTS built_against ( against_reference text NOT NULL, PRIMARY KEY (module, commit_sha, against_reference) ); - -CREATE INDEX IF NOT EXISTS built_against_target ON built_against (against_reference); `; // The graph first keyed its edges on a module and a commit, which the builder does not know and @@ -107,6 +105,11 @@ DELETE FROM built_against WHERE against_reference IS NULL; ALTER TABLE built_against DROP COLUMN IF EXISTS against_module; ALTER TABLE built_against DROP COLUMN IF EXISTS against_commit; ALTER TABLE built_against ALTER COLUMN against_reference SET NOT NULL; + +-- After the column exists, and not in the schema above: on a store that still has the old shape, +-- the table is not created, so an index named in the same breath would be built on a column that +-- is not there yet. +CREATE INDEX IF NOT EXISTS built_against_target ON built_against (against_reference); `; export class Graph { -- 2.54.0 From c80d9d0f7cd403cf914f68478f65bcd71e0751a7 Mon Sep 17 00:00:00 2001 From: jochen Date: Sun, 13 Sep 2026 01:44:56 +0200 Subject: [PATCH 15/21] The graph holds what a module declares, not only what it was built on MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Build edges are discovered by building; requires and provides are stated by the module about itself. Both belong in the graph and answer different questions — and "what provides postgres-database" needed a sweep over every manifest, which only something holding all of them can do. --- modules/mesh-catalog/store.ts | 79 +++++++++++++++++++++++++++++ modules/mesh-catalog/tools/index.ts | 11 ++++ 2 files changed, 90 insertions(+) diff --git a/modules/mesh-catalog/store.ts b/modules/mesh-catalog/store.ts index 0e6aa58..8e1f87f 100644 --- a/modules/mesh-catalog/store.ts +++ b/modules/mesh-catalog/store.ts @@ -87,6 +87,28 @@ CREATE TABLE IF NOT EXISTS module_artifact ( CREATE INDEX IF NOT EXISTS module_artifact_reference ON module_artifact (reference); +-- What a module-version declares. Separate from the build edges above and never mixed with them: +-- a build edge is a fact about what was compiled, discovered by building it, and these are +-- intentions the module states about itself (ADR 0072). They answer different questions and go +-- stale for different reasons. +CREATE TABLE IF NOT EXISTS module_requires ( + module text NOT NULL, + commit_sha text NOT NULL, + requirement text NOT NULL, + PRIMARY KEY (module, commit_sha, requirement) +); + +CREATE TABLE IF NOT EXISTS module_provides ( + module text NOT NULL, + commit_sha text NOT NULL, + provision text NOT NULL, + scope text NOT NULL DEFAULT '', + PRIMARY KEY (module, commit_sha, provision) +); + +CREATE INDEX IF NOT EXISTS module_provides_provision ON module_provides (provision); +CREATE INDEX IF NOT EXISTS module_requires_requirement ON module_requires (requirement); + CREATE TABLE IF NOT EXISTS built_against ( module text NOT NULL, commit_sha text NOT NULL, @@ -154,6 +176,34 @@ export class Graph { // Both are replaced rather than added to: they describe this build, and a previous build of // the same commit that saw different artifacts was wrong about one of them. + // Read from the manifest rather than taken as a separate argument: the manifest the builder + // resolved IS the module's declaration, and a second copy passed alongside it could disagree + // with it. Replaced wholesale, because a version declares exactly one set of these. + const declared = (version.manifest ?? {}) as { + requires?: unknown[]; + provides?: unknown[]; + }; + await client.query(`DELETE FROM module_requires WHERE module = $1 AND commit_sha = $2`, + [version.module, version.commit]); + for (const r of declared.requires ?? []) { + const name = typeof r === "string" ? r : String((r as { name?: unknown }).name ?? ""); + if (!name) continue; + await client.query( + `INSERT INTO module_requires (module, commit_sha, requirement) VALUES ($1,$2,$3) + ON CONFLICT DO NOTHING`, [version.module, version.commit, name]); + } + await client.query(`DELETE FROM module_provides WHERE module = $1 AND commit_sha = $2`, + [version.module, version.commit]); + for (const pv of declared.provides ?? []) { + const name = typeof pv === "string" ? pv : String((pv as { name?: unknown }).name ?? ""); + const scope = typeof pv === "string" ? "" : String((pv as { scope?: unknown }).scope ?? ""); + if (!name) continue; + await client.query( + `INSERT INTO module_provides (module, commit_sha, provision, scope) VALUES ($1,$2,$3,$4) + ON CONFLICT (module, commit_sha, provision) DO UPDATE SET scope = excluded.scope`, + [version.module, version.commit, name, scope]); + } + await client.query(`DELETE FROM module_artifact WHERE module = $1 AND commit_sha = $2`, [version.module, version.commit]); for (const a of made) { @@ -285,6 +335,35 @@ export class Graph { return stale.filter((s) => !s.because.some((b) => waiting.has(b.module))); } + /** + * What provides a given provision, and what needs it. + * + * **Answered from the graph rather than by scanning manifests**, which is how it is answered + * today — a sweep over every module's declaration, which only whoever holds every declaration + * can do and which is wrong the moment one of them changes. + */ + async whoProvides(provision: string): Promise<{ + provides: { module: string; commit: string; scope: string }[]; + requires: { module: string; commit: string }[]; + }> { + const provides = await this.pool.query( + `SELECT p.module, p.commit_sha, p.scope + FROM module_provides p + JOIN module_current c ON c.module = p.module AND c.commit_sha = p.commit_sha + WHERE p.provision = $1 ORDER BY p.module`, [provision]); + const requires = await this.pool.query( + `SELECT r.module, r.commit_sha + FROM module_requires r + JOIN module_current c ON c.module = r.module AND c.commit_sha = r.commit_sha + WHERE r.requirement = $1 ORDER BY r.module`, [provision]); + return { + provides: provides.rows.map((r) => ({ + module: r.module as string, commit: r.commit_sha as string, scope: r.scope as string })), + requires: requires.rows.map((r) => ({ + module: r.module as string, commit: r.commit_sha as string })), + }; + } + async close(): Promise { await this.pool.end(); } diff --git a/modules/mesh-catalog/tools/index.ts b/modules/mesh-catalog/tools/index.ts index 09a1cba..0c4efcc 100644 --- a/modules/mesh-catalog/tools/index.ts +++ b/modules/mesh-catalog/tools/index.ts @@ -31,6 +31,17 @@ export function getCatalogueTools(graph: Graph): ToolDefinition[] { return found ? { module: found } : { error: `the catalogue holds no ${module}` }; }, }, + { + name: "catalog_provides", + description: + "What provides a provision, and what needs it. Answered from what modules declare about themselves, so it does not require holding every manifest at once.", + input: { provision: { type: "string", description: "the provision, e.g. postgres-database" } }, + run: async (args) => { + const provision = String(args.provision ?? ""); + if (!provision) return { error: "name a provision" }; + return await graph.whoProvides(provision); + }, + }, { name: "catalog_dependents", description: -- 2.54.0 From d5300120d654b4c1e9b1c16ab86095509667d46c Mon Sep 17 00:00:00 2001 From: jochen Date: Sun, 13 Sep 2026 01:49:48 +0200 Subject: [PATCH 16/21] Move amqp-ping's commit, to see what the catalogue announces --- modules/amqp-ping/README.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 modules/amqp-ping/README.md diff --git a/modules/amqp-ping/README.md b/modules/amqp-ping/README.md new file mode 100644 index 0000000..806bab9 --- /dev/null +++ b/modules/amqp-ping/README.md @@ -0,0 +1 @@ +// touched to move the commit: proving an upgrade is announced -- 2.54.0 From 358c7d5a2ded853c9abe2cfca6e0eb9f2a31c31d Mon Sep 17 00:00:00 2001 From: jochen Date: Sun, 13 Sep 2026 01:57:00 +0200 Subject: [PATCH 17/21] Move postgres's commit, to watch the mesh roll it out by itself --- modules/postgres/Dockerfile | 2 ++ 1 file changed, 2 insertions(+) diff --git a/modules/postgres/Dockerfile b/modules/postgres/Dockerfile index d502d08..43cbc3b 100644 --- a/modules/postgres/Dockerfile +++ b/modules/postgres/Dockerfile @@ -34,3 +34,5 @@ COPY --from=build /app/modules/postgres/dist /app/modules/postgres/dist # and is not listed here — the declaration names it in the container's `args`, because it is what # this module's own container runs. One image, because they are one module and share a client. ENV MESH_TOOL_MODULES=/app/modules/postgres/dist/index.js,/app/modules/postgres/dist/tools/index.js + +# Moved to prove the mesh follows its own catalogue. -- 2.54.0 From 1d0d9a389486833f7d49cafbf8bdde9335508213 Mon Sep 17 00:00:00 2001 From: jochen Date: Sun, 13 Sep 2026 01:58:48 +0200 Subject: [PATCH 18/21] Name the module in its own runtime, and move the artifact with it --- modules/postgres/Dockerfile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/modules/postgres/Dockerfile b/modules/postgres/Dockerfile index 43cbc3b..80bef5f 100644 --- a/modules/postgres/Dockerfile +++ b/modules/postgres/Dockerfile @@ -34,5 +34,5 @@ COPY --from=build /app/modules/postgres/dist /app/modules/postgres/dist # and is not listed here — the declaration names it in the container's `args`, because it is what # this module's own container runs. One image, because they are one module and share a client. ENV MESH_TOOL_MODULES=/app/modules/postgres/dist/index.js,/app/modules/postgres/dist/tools/index.js - -# Moved to prove the mesh follows its own catalogue. +# A real change, so the artifact itself moves and not only the commit. +ENV MESH_MODULE=postgres -- 2.54.0 From 87243bc524f80b2b083b84cdc8c1984693af53e4 Mon Sep 17 00:00:00 2001 From: jochen Date: Sun, 13 Sep 2026 02:45:37 +0200 Subject: [PATCH 19/21] Every module stands on a base the mesh built MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The base was a digest typed in by hand, for an image nothing in the mesh could produce — so the graph held edges pointing at it with no version on the far end, and the one change that reaches every module at once could never be noticed. It is a module now, and these edges resolve. --- modules/amqp-ping/Dockerfile | 2 +- modules/mesh-catalog/Dockerfile | 2 +- modules/postgres/Dockerfile | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/modules/amqp-ping/Dockerfile b/modules/amqp-ping/Dockerfile index 2f377a3..3f4a7b1 100644 --- a/modules/amqp-ping/Dockerfile +++ b/modules/amqp-ping/Dockerfile @@ -7,7 +7,7 @@ # # The base is named by ARG so it can be pinned to a digest the mesh's registry assigned. A tag here # would make the runtime's contents depend on what somebody last pushed under that name. -ARG RUNTIME_BASE=127.0.0.1:5000/mesh-tool-runtime@sha256:d4d793c828a5f563fdd8f49e5c6026d17b308a3f7a13c589849188362e4c7415 +ARG RUNTIME_BASE=127.0.0.1:5000/mesh-tools/runtime@sha256:44b5d8bc30107fdc3bffdaebc1ca2de97615053853f826dfccce253a01a163fd FROM ${RUNTIME_BASE} AS build # Compiled under /app/modules, so resolving `@novox/mesh-sdk` walks up to the base's own diff --git a/modules/mesh-catalog/Dockerfile b/modules/mesh-catalog/Dockerfile index 3fb4317..5331f6b 100644 --- a/modules/mesh-catalog/Dockerfile +++ b/modules/mesh-catalog/Dockerfile @@ -7,7 +7,7 @@ # # The base is named by ARG so it can be pinned to a digest the mesh's registry assigned. A tag would # make this runtime's contents depend on what somebody last pushed under that name. -ARG RUNTIME_BASE=127.0.0.1:5000/mesh-tool-runtime@sha256:d4d793c828a5f563fdd8f49e5c6026d17b308a3f7a13c589849188362e4c7415 +ARG RUNTIME_BASE=127.0.0.1:5000/mesh-tools/runtime@sha256:44b5d8bc30107fdc3bffdaebc1ca2de97615053853f826dfccce253a01a163fd FROM ${RUNTIME_BASE} AS build # Compiled under /app/modules so `@novox/mesh-sdk` resolves upward into the base's own diff --git a/modules/postgres/Dockerfile b/modules/postgres/Dockerfile index 80bef5f..202cafc 100644 --- a/modules/postgres/Dockerfile +++ b/modules/postgres/Dockerfile @@ -8,7 +8,7 @@ # # The base is named by ARG so it can be pinned to a digest the mesh's registry assigned. A tag would # make this runtime's contents depend on what somebody last pushed under that name. -ARG RUNTIME_BASE=127.0.0.1:5000/mesh-tool-runtime@sha256:d4d793c828a5f563fdd8f49e5c6026d17b308a3f7a13c589849188362e4c7415 +ARG RUNTIME_BASE=127.0.0.1:5000/mesh-tools/runtime@sha256:44b5d8bc30107fdc3bffdaebc1ca2de97615053853f826dfccce253a01a163fd FROM ${RUNTIME_BASE} AS build # Compiled under /app/modules so `@novox/mesh-sdk` resolves upward into the base's own -- 2.54.0 From 21ad008879b62aa181c6e682ac3c5fd86adf06e4 Mon Sep 17 00:00:00 2001 From: jochen Date: Sun, 13 Sep 2026 02:48:21 +0200 Subject: [PATCH 20/21] Stale means the artifact moved, not the commit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A comment changed in a build recipe is a new commit and a byte-identical image. Comparing commits called every module standing on it stale, so the mesh would have rebuilt itself entirely to arrive back exactly where it started — and listed each dependent once per commit that had produced the same image. --- modules/mesh-catalog/store.ts | 32 +++++++++++++++++++++++++++----- 1 file changed, 27 insertions(+), 5 deletions(-) diff --git a/modules/mesh-catalog/store.ts b/modules/mesh-catalog/store.ts index 8e1f87f..5b187ef 100644 --- a/modules/mesh-catalog/store.ts +++ b/modules/mesh-catalog/store.ts @@ -279,11 +279,15 @@ export class Graph { * Resolved through the artifacts each version published, because that is what an edge names. */ async dependents(module: string): Promise<{ module: string; commit: string; againstCommit: string }[]> { + // Distinct, because one artifact may have been published by more than one version of the + // module that made it — several commits producing a byte-identical image — and a dependent + // would otherwise be listed once per such commit, as though it were several dependents. const { rows } = await this.pool.query( - `SELECT b.module, b.commit_sha, a.commit_sha AS against_commit + `SELECT DISTINCT b.module, b.commit_sha, c2.commit_sha AS against_commit FROM built_against b JOIN module_current c ON c.module = b.module AND c.commit_sha = b.commit_sha JOIN module_artifact a ON a.reference = b.against_reference + JOIN module_current c2 ON c2.module = a.module WHERE a.module = $1 ORDER BY b.module`, [module]); return rows.map((r) => ({ @@ -301,14 +305,32 @@ export class Graph { * chain and a diamond need no special handling and nothing has to know the shape in advance. */ async stale(): Promise { + // **Compared by artifact, not by commit.** A commit moving is not the same as the thing it + // produces moving: a change to a comment in a build recipe is a new commit and a byte-identical + // image, and calling everything built on it stale would mean rebuilding the whole mesh to + // arrive back exactly where it started. What makes a module stale is that the artifact it was + // built against is no longer one the current version publishes. + // + // The producing version is picked as the earliest that published this artifact, because when + // several commits produce the identical image, the first one is where it actually came from. const { rows } = await this.pool.query( - `SELECT b.module, b.commit_sha, a.module AS against_module, a.commit_sha AS against_commit, + `SELECT b.module, b.commit_sha, p.module AS against_module, p.commit_sha AS against_commit, c2.commit_sha AS now_at FROM built_against b JOIN module_current c1 ON c1.module = b.module AND c1.commit_sha = b.commit_sha - JOIN module_artifact a ON a.reference = b.against_reference - JOIN module_current c2 ON c2.module = a.module - WHERE c2.commit_sha <> a.commit_sha + JOIN LATERAL ( + SELECT a.module, a.commit_sha + FROM module_artifact a + JOIN module_version v ON v.module = a.module AND v.commit_sha = a.commit_sha + WHERE a.reference = b.against_reference + ORDER BY v.registered + LIMIT 1 + ) p ON true + JOIN module_current c2 ON c2.module = p.module + WHERE NOT EXISTS ( + SELECT 1 FROM module_artifact now + WHERE now.module = c2.module AND now.commit_sha = c2.commit_sha + AND now.reference = b.against_reference) ORDER BY b.module`); const by = new Map(); for (const r of rows) { -- 2.54.0 From 729582cc55e1d2a2bf11c0a75f1019d34eacb1ac Mon Sep 17 00:00:00 2001 From: jochen Date: Sun, 13 Sep 2026 11:15:33 +0200 Subject: [PATCH 21/21] Remove what was only there to move a commit A line in postgres's recipe and a one-line file in amqp-ping, both added to move a commit and watch the mesh notice. The proofs worked; neither was meant to stay. MESH_MODULE is set from the sealed credential at run time anyway, so baking it in was dead weight as well as noise. --- modules/amqp-ping/README.md | 1 - modules/postgres/Dockerfile | 2 -- 2 files changed, 3 deletions(-) delete mode 100644 modules/amqp-ping/README.md diff --git a/modules/amqp-ping/README.md b/modules/amqp-ping/README.md deleted file mode 100644 index 806bab9..0000000 --- a/modules/amqp-ping/README.md +++ /dev/null @@ -1 +0,0 @@ -// touched to move the commit: proving an upgrade is announced diff --git a/modules/postgres/Dockerfile b/modules/postgres/Dockerfile index 202cafc..aa74659 100644 --- a/modules/postgres/Dockerfile +++ b/modules/postgres/Dockerfile @@ -34,5 +34,3 @@ COPY --from=build /app/modules/postgres/dist /app/modules/postgres/dist # and is not listed here — the declaration names it in the container's `args`, because it is what # this module's own container runs. One image, because they are one module and share a client. ENV MESH_TOOL_MODULES=/app/modules/postgres/dist/index.js,/app/modules/postgres/dist/tools/index.js -# A real change, so the artifact itself moves and not only the commit. -ENV MESH_MODULE=postgres -- 2.54.0