diff --git a/src/runtime.ts b/src/runtime.ts index ea0d36e..d25fa85 100644 --- a/src/runtime.ts +++ b/src/runtime.ts @@ -59,7 +59,14 @@ export async function runTools(opts: RuntimeOptions): Promise<() => void> { // both: its tools are the module's and the seat's verbs alike. const self = opts.credential?.module; const seatNames = new Set((opts.credential?.claims ?? []).map((c) => c.seat)); - const ownRegistrations = collectTools().filter(({ module }) => module === self || !seatNames.has(module)); + // A registration under a name that is neither this module nor a seat it claims is not served: + // said, and left out, rather than fatal — on 2026-10-01 the credential of a module that had just + // learned to implement a seat did not yet name the claim, and the whole runtime restarted for it. + const ownRegistrations = collectTools().filter(({ module }) => { + if (module === self || !self || seatNames.has(module)) return module === self || !self; + console.log(`[mesh-tools] ${self} registers tools under "${module}", which is neither this module nor a seat its credential claims; not served until the mesh issues the claim`); + return false; + }); const tools = ownRegistrations.flatMap(({ module, tools: own }) => own.map((t) => ({ module, name: t.name }))); const stops: Array<() => void> = []; const stop = (): void => stops.splice(0).forEach((s) => s()); diff --git a/test/fixtures/store-seat-unclaimed.mjs b/test/fixtures/store-seat-unclaimed.mjs new file mode 100644 index 0000000..8c6bde4 --- /dev/null +++ b/test/fixtures/store-seat-unclaimed.mjs @@ -0,0 +1,12 @@ +// 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/test/membership.test.ts b/test/membership.test.ts index 50014e0..2ceb922 100644 --- a/test/membership.test.ts +++ b/test/membership.test.ts @@ -195,3 +195,31 @@ test("a mesh that has issued nothing yet gets the derived shape, and says so", a await stream.close(); } }); + +// A module that implements a seat its credential does not (yet) claim is not served for it and does +// not fall over either: the runtime says so and serves the module's own tools. +test("a registration under a seat the credential does not claim is said and skipped, not fatal", async (t) => { + if (!url) return t.skip("MESH_TEST_NATS unset"); + resetTools(); + const stream = await anAssignmentsStream(); + const credential = { url, node: "anchor", module: "postgres" }; + const pg = await connectNats(credential); + const asker = await connectNats({ url, module: "console", node: "workstation" }); + const said: string[] = []; + const log = console.log; + console.log = (...a: unknown[]) => said.push(a.join(" ")); + let stop = () => {}; + try { + stop = await runTools({ broker: pg, credential, moduleEntrypoints: [fixture("store-seat-unclaimed.mjs")] }); + console.log = log; + assert.ok(said.some((s) => /registers tools under "mesh-store".*not served/.test(s)), said.join("\n")); + assert.deepEqual((await callTool(asker, "postgres.databases", {})).result, { software: "postgres" }); + await assert.rejects(callTool(asker, "seat:mesh-store.databases", {}), /no responders|503/i); + } finally { + console.log = log; + stop(); + await asker.close(); + await pg.close(); + await stream.close(); + } +});