From 0cea8d286e7b45283556b0c30d6ad76de68693b9 Mon Sep 17 00:00:00 2001 From: jochen Date: Sat, 3 Oct 2026 23:48:15 +0200 Subject: [PATCH] Serve a seat's verbs from the membership once one is issued (novox/hq issue 218) The runtime added every seat its start-up credential claims even after the mesh issued a membership without it. A seat held once for the mesh is claimed on every machine running the module, so ace's postgres announced the store seat the bus then refused it on. --- node-tools/src/runtime.ts | 13 +++++---- node-tools/test/fixtures/store-claimant.mjs | 13 +++++++++ node-tools/test/membership.test.ts | 30 +++++++++++++++++++++ 3 files changed, 51 insertions(+), 5 deletions(-) create mode 100644 node-tools/test/fixtures/store-claimant.mjs diff --git a/node-tools/src/runtime.ts b/node-tools/src/runtime.ts index 578d32b..99e7ae5 100644 --- a/node-tools/src/runtime.ts +++ b/node-tools/src/runtime.ts @@ -342,13 +342,16 @@ async function serveClaimedSeats( for (const module of served) { const m = typeof broker.membership === "function" ? broker.membership(module) : undefined; for (const s of m?.seats ?? []) add({ seat: s.seat, verb: s.verb, subject: s.subject, holder: module }); - // The credential's claims, for the module's own runtime: where the mesh issued the verb when - // it has; the derived shape until then. - if (module !== self) continue; + // The credential's claims, for the module's own runtime, ONLY until the mesh issues a + // membership (novox/hq issue 218). The credential names what the module claims; the membership + // names what it holds on this machine. A seat held once for the mesh is claimed by every + // machine running the module and held by one, so once a membership exists it decides: a claim + // it leaves out is not held here, and serving it anyway announced the seat from a machine the + // bus then refused it on. + if (module !== self || m) continue; for (const claim of credential?.claims ?? []) { for (const verb of claim.serves ?? []) { - const subject = m?.seats?.find((s) => s.seat === claim.seat && s.verb === verb)?.subject - ?? seatToolSubject(claim.seat, verb, claim.scope, credential?.node); + const subject = seatToolSubject(claim.seat, verb, claim.scope, credential?.node); add({ seat: claim.seat, verb, subject, holder: module }); } } diff --git a/node-tools/test/fixtures/store-claimant.mjs b/node-tools/test/fixtures/store-claimant.mjs new file mode 100644 index 0000000..db18307 --- /dev/null +++ b/node-tools/test/fixtures/store-claimant.mjs @@ -0,0 +1,13 @@ +// A copy for the issue 218 test: a fixture registers once per process, at import. +// A module that is both software and a role: postgres's own tools under its name, and its +// implementation of the mesh-store seat's verbs under the seat's (novox/hq ADR 0159, 0160). +import { registerModuleTools } from "@novox/mesh-sdk/tools"; + +registerModuleTools("postgres", () => [ + { name: "postgres_create_database", description: "make one", input: {}, run: async () => ({ made: true }) }, + { name: "databases", description: "postgres's own listing", input: {}, run: async () => ({ software: "postgres" }) }, +]); + +registerModuleTools("mesh-store", () => [ + { name: "databases", description: "what the store holds", input: {}, run: async () => ({ seat: "mesh-store" }) }, +]); diff --git a/node-tools/test/membership.test.ts b/node-tools/test/membership.test.ts index 2ceb922..6d9eda9 100644 --- a/node-tools/test/membership.test.ts +++ b/node-tools/test/membership.test.ts @@ -140,6 +140,36 @@ test("a seat's verbs are implemented under the seat's name, served where issued, } }); +// novox/hq issue 218: the store seat is claimed by every machine running postgres and held by one. +// Where the mesh issued a membership without the seat, the claim in the credential serves nothing: +// the module's own tools answer, the seat's verbs do not, and the runtime does not announce them. +test("a claimant the membership does not make the holder serves none of the seat's verbs", async (t) => { + if (!url) return t.skip("MESH_TEST_NATS unset"); + resetTools(); + const stream = await anAssignmentsStream(); + await stream.issue({ + node: "elsewhere", + module: "postgres", + serves: [{ subject: "mesh.mod.postgres.tool.{tool}.elsewhere" }], + emits: "mesh.mod.postgres.event.{event}", + tools: "mesh.mod.postgres.tool.tools", + }); + const credential = { url, node: "elsewhere", module: "postgres", claims: [{ seat: "mesh-store", scope: "mesh", serves: ["databases", "query"] }] }; + const pg = await connectNats(credential); + const asker = await connectNats({ url, module: "console", node: "workstation" }); + let stop = () => {}; + try { + stop = await runTools({ broker: pg, credential, moduleEntrypoints: [fixture("store-claimant.mjs")] }); + assert.deepEqual((await callTool(asker, "postgres.databases@elsewhere", {})).result, { software: "postgres" }); + await assert.rejects(callTool(asker, "seat:mesh-store.databases", {}), /no responders|503/i, "the store is not held here"); + } finally { + stop(); + await asker.close(); + await pg.close(); + await stream.close(); + } +}); + test("a module named like its seat registers once, and answers as the module and as the seat", async (t) => { if (!url) return t.skip("MESH_TEST_NATS unset"); resetTools();