Split describing the mesh from the catalogue holding it

Two claims were bundled in one step: that the control plane can describe the mesh
correctly, and that the catalogue holds a complete record of what was built. The
first passes; the second is novox/hq issue 050. Bundled, one open fault stopped
three later steps from ever being attempted, which is exactly the information the
run existed to produce.

The catalogue is now measured against what the control plane ordered rather than
against a list written in the test — a catalogue cannot know what it was never
told, so it has to be compared with something that does.

Claude-Session: https://claude.ai/code/session_01D6qtiYU3P9jk3pnAXyAFyx
This commit is contained in:
2026-09-14 22:17:06 +02:00
parent 3690e17cf4
commit d0d56782a0
2 changed files with 201 additions and 22 deletions
+133
View File
@@ -0,0 +1,133 @@
{
"scenario": "one-node-mesh",
"established": 14,
"of": 18,
"steps": [
{
"code": "R1",
"title": "a bare machine becomes a mesh of one, raised by the installer",
"status": "pass",
"seconds": 132,
"why": ""
},
{
"code": "R2",
"title": "the substrate is up — a store and a broker of the mesh's own",
"status": "pass",
"seconds": 1,
"why": ""
},
{
"code": "R3",
"title": "the control plane is one this mesh built, not one it was handed",
"status": "pass",
"seconds": 0,
"why": ""
},
{
"code": "R4",
"title": "the pivot finished — what raised the mesh is gone",
"status": "pass",
"seconds": 0,
"why": ""
},
{
"code": "R5",
"title": "the registry serves this mesh its own images",
"status": "pass",
"seconds": 0,
"why": ""
},
{
"code": "R6",
"title": "the machine is enrolled, and an agent is running on it",
"status": "pass",
"seconds": 0,
"why": ""
},
{
"code": "R7",
"title": "the builder is installed as a module, with an account",
"status": "pass",
"seconds": 0,
"why": ""
},
{
"code": "P1",
"title": "the mesh builds the shared base from source",
"status": "pass",
"seconds": 84,
"why": ""
},
{
"code": "P2",
"title": "the mesh builds and runs a store of its own",
"status": "pass",
"seconds": 40,
"why": ""
},
{
"code": "P3",
"title": "the mesh builds and runs its own catalogue",
"status": "pass",
"seconds": 34,
"why": ""
},
{
"code": "P4",
"title": "the mesh rebuilds its own control plane from source",
"status": "pass",
"seconds": 36,
"why": ""
},
{
"code": "U1",
"title": "the mesh builds a module standing on that base",
"status": "pass",
"seconds": 14,
"why": ""
},
{
"code": "U2",
"title": "the mesh runs a broker for that module to talk to",
"status": "pass",
"seconds": 24,
"why": ""
},
{
"code": "U3",
"title": "the anchor runs the module the mesh built",
"status": "pass",
"seconds": 6,
"why": ""
},
{
"code": "V1",
"title": "the mesh can describe itself, and what it says is true",
"status": "fail",
"seconds": 2,
"why": "the catalogue does not know about mesh-tools:\n{\"modules\":[{\"module\":\"amqp-ping\",\"commit\":\"af1e3afa495bac11e74c2d76cb2cf7a78167f2f6\",\"repository\":\"https://git.novox.be/novox/mesh-catalog.git\",\"path\":\"modules/amqp-ping\"},{\"module\":\"lavinmq\",\"commit\":\"af1e3afa495bac11e74c2d76cb2cf7a78167f2f6\",\"repository\":\"https://git.novox.be/novox/mesh-catalog.git\",\"path\":\"modules/lavinmq\"},{\"module\":\"mesh-control\",\"commit\":\"5062c36fc9efe159aa9706c0ca2c873351ef1ce0\",\"repository\":\"https://git.novox.be/novox/mesh-control.git\",\"path\":\"\"}]}\n"
},
{
"code": "V2",
"title": "the machine's networking is what the modules asked for",
"status": "skip",
"seconds": 0,
"why": "not attempted — V1 (the mesh can describe itself, and what it says is true) did not succeed"
},
{
"code": "E1",
"title": "a change to a module's source reaches the machine on its own",
"status": "skip",
"seconds": 0,
"why": "not attempted — V2 (the machine's networking is what the modules asked for) did not succeed"
},
{
"code": "E2",
"title": "the mesh comes back after the machine reboots",
"status": "skip",
"seconds": 0,
"why": "not attempted — E1 (a change to a module's source reaches the machine on its own) did not succeed"
}
]
}
+68 -22
View File
@@ -83,6 +83,8 @@ const PROVIDER = { module: "lavinmq", repo: "mesh-catalog", path: "modules/lavin
* what a change reaches, or what must be rebuilt. It ran anyway, which is the point: "the mesh is
* up" was being read off genesis finishing.
*/
/** The one word that puts a mesh on a private network and gives its machines names. */
const NETWORK_MODULE = "networking";
const STORE = { module: "postgres", repo: "mesh-catalog", path: "modules/postgres", container: "mesh-postgres" };
const CATALOGUE = { module: "mesh-catalog", repo: "mesh-catalog", path: "modules/mesh-catalog", container: "mesh-catalog" };
/** The control plane, rebuilt from its own repository — the step that ends the installer's tenure. */
@@ -113,9 +115,11 @@ const BASE_BUILT = "the mesh builds the shared base from source";
const STORE_RUNS = "the mesh builds and runs a store of its own";
const CATALOGUE_RUNS = "the mesh builds and runs its own catalogue";
const CONTROL_REBUILT = "the mesh rebuilds its own control plane from source";
const NETWORKED = "the mesh puts itself on a private network, and its machine has a name";
const MODULE_BUILT = "the mesh builds a module standing on that base";
const ANCHOR_RUNS = "the anchor runs the module the mesh built";
const DESCRIBES = "the mesh can describe itself, and what it says is true";
const DESCRIBES = "the control plane can describe the mesh, and what it says is true";
const CATALOGUED = "the catalogue holds every module this mesh built";
const NETWORK = "the machine's networking is what the modules asked for";
const FOLLOWS = "a change to a module's source reaches the machine on its own";
const SURVIVES = "the mesh comes back after the machine reboots";
@@ -371,11 +375,13 @@ const PLAN: { code: string; title: string }[] = [
{ code: "P2", title: STORE_RUNS },
{ code: "P3", title: CATALOGUE_RUNS },
{ code: "P4", title: CONTROL_REBUILT },
{ code: "N1", title: NETWORKED },
{ code: "U1", title: MODULE_BUILT },
{ code: "U2", title: NEEDS },
{ code: "U3", title: ANCHOR_RUNS },
{ code: "V1", title: DESCRIBES },
{ code: "V2", title: NETWORK },
{ code: "V2", title: CATALOGUED },
{ code: "V3", title: NETWORK },
{ code: "E1", title: FOLLOWS },
{ code: "E2", title: SURVIVES },
];
@@ -580,8 +586,38 @@ before(async () => {
return `${built}\n${rolled}`;
});
// ---- THE MESH'S OWN NETWORKING ---------------------------------------------------------------
//
// **Four modules the control plane computes were assigned to nothing, and nothing complained.**
// `networking`, `mesh-wireguard`, `mesh-names` and `mesh-resolver` all existed as records that
// had never been placed on a machine — so no names were written, no private network was raised,
// and `/etc/hosts` held nothing. A module is a definition until it is assigned; being generated
// by the control plane does not place it.
//
// One word, by design: `networking` has no files of its own and is requirements only, so
// assigning it finds one answer to each and takes them. The day the catalogue holds a second VPN
// there are two answers, the mesh refuses and names both, and choosing is assigning the one you
// want.
await step("N1", NETWORKED, CONTROL_REBUILT, async () => {
await mesh(`assign ${CONTROL} ${NETWORK_MODULE}`);
await mesh(`push ${CONTROL}`, 600_000);
// What it was assigned for. A machine on a private network with no name on it has had the
// harder half done and the visible half not.
const deadline = Date.now() + 120_000;
let hosts = "";
while (Date.now() < deadline) {
hosts = (await on(CONTROL, `cat /etc/hosts`)).out;
if (/\.internal/.test(hosts)) break;
await new Promise((r) => setTimeout(r, 5_000));
}
assert.match(hosts, /\.internal/,
`${NETWORK_MODULE} is assigned and no machine has a name:\n${hosts}`);
const modules = await mesh("module list");
return `${hosts.trim()}\n\n${modules.trim()}`;
});
// ---- 7..8. SOMETHING TO RUN ---------------------------------------------------------------
await step("U1", MODULE_BUILT, CONTROL_REBUILT, async () => {
await step("U1", MODULE_BUILT, NETWORKED, async () => {
await registerModule(MODULE.module, resolve(catalogDir, MODULE.module, "module.json"));
const built = await mesh(
`build ${forgeUrl(MODULE.repo)} --path ${MODULE.path} --ref ${refFor(MODULE.repo)} --wait 1200s`,
@@ -660,18 +696,24 @@ before(async () => {
said.push(` plan every image pinned, no placeholders`);
// And the catalogue, ASKED rather than observed. These five questions are what it exists for.
return said.join("\n");
});
// ---- V2. AND THE CATALOGUE HOLDS WHAT WAS BUILT -----------------------------------------------
//
// **Split from the step above, because they are two claims and only one of them fails.** The
// control plane describing the mesh correctly and the catalogue holding a complete record of it
// are different things, and bundling them meant one open fault stopped three later steps from
// ever being attempted.
await step("V2", CATALOGUED, DESCRIBES, async () => {
// **Asked as an operator would have to, which turns out to be nobody.**
//
// The obvious move — run the invoke inside the catalogue's own container — is refused by the
// broker: `User 'anchor-mesh-catalog' doesn't have permissions to queue 'amq.gen-…'`. A
// module's account is scoped to what it declares it emits and consumes, and calling a tool
// needs a temporary reply queue, which that scope does not cover. So a module can SERVE tools
// broker: a module's account is scoped to what it declares it emits and consumes, and calling
// a tool needs a temporary reply queue that scope does not cover. So a module can SERVE tools
// and cannot CALL them, and nothing issues an account to anyone who wants to ask (novox/hq
// issue 049).
//
// Until that is decided, the caller is the substrate's own admin account over the broker's
// loopback — the bootstrap case `mesh-tools` documents, reached the way genesis reaches a
// substrate container, by joining its network namespace.
// issue 049). Until that is decided the caller is the substrate's bootstrap admin over the
// broker's loopback, reached by joining its network namespace.
const image = (await on(CONTROL,
`docker inspect -f '{{.Config.Image}}' mesh-catalog`)).out.trim();
const ask = async (tool: string, args = "{}") =>
@@ -682,30 +724,32 @@ before(async () => {
120_000);
const held = await ask("catalog_modules");
for (const m of MUST_HOLD) {
if (m === "registry" || m === "builder" || m === "mesh-control") continue; // carried, not built here
assert.ok(held.includes(m), `the catalogue does not know about ${m}:\n${held}`);
}
// Compared against what the control plane ordered, rather than against a list written here: a
// catalogue cannot know what it was never told, so it must be measured against something that
// does. novox/hq issue 050 — on a fresh mesh the modules built before the catalogue existed
// are exactly the ones it needed in order to exist, so the hole is always the foundation.
const missing = MUST_HOLD.filter((m) =>
!["registry", "builder"].includes(m) && !held.includes(m));
assert.deepEqual(missing, [],
`the catalogue does not hold ${missing.join(", ")} — the mesh built them and its own ` +
`record has no trace of it (novox/hq issue 050):\n${held}`);
assert.doesNotMatch(held, /sha256:0{64}/, `the catalogue holds a placeholder version`);
said.push(` catalog_modules every built module, each with a version this mesh made`);
const provides = await ask("catalog_provides", JSON.stringify({ provision: "amqp" }));
assert.ok(provides.includes(PROVIDER.module),
`the catalogue cannot say what provides amqp, which is the question it exists to answer:\n${provides}`);
said.push(` catalog_provides amqp is answered by ${PROVIDER.module}`);
`the catalogue cannot say what provides amqp, which is a question it exists for:\n${provides}`);
const stale = await ask("catalog_stale");
said.push(` catalog_stale ${stale.trim().slice(0, 120)}`);
return said.join("\n");
return `${held}\n${provides}\n${stale}`;
});
// ---- 10. AND ITS NETWORKING IS WHAT WAS ASKED FOR ---------------------------------------------
// ---- V3. AND ITS NETWORKING IS WHAT WAS ASKED FOR ---------------------------------------------
//
// **Left out of this test entirely until it was pointed out**, which is hard to defend: the
// firewall is generated from what modules declare they listen on, and a firewall that opens the
// wrong set is either a service nobody can reach or a port nobody meant to publish. Neither shows
// up as a failed container.
await step("V2", NETWORK, DESCRIBES, async () => {
await step("V3", NETWORK, DESCRIBES, async () => {
const said: string[] = [];
const ruleset = (await on(CONTROL, `nft list table inet mesh 2>&1`)).out;
@@ -811,10 +855,12 @@ for (const name of [
STORE_RUNS,
CATALOGUE_RUNS,
CONTROL_REBUILT,
NETWORKED,
MODULE_BUILT,
NEEDS,
ANCHOR_RUNS,
DESCRIBES,
CATALOGUED,
NETWORK,
FOLLOWS,
SURVIVES,