Commit Graph
100 Commits
Author SHA1 Message Date
jschoubben b9cdb96a90 Merge pull request 'A report that arrives while the store restarts is kept, not lost (issue 082)' (#42) from multiple-fixes into main 2026-09-22 13:53:13 +02:00
jschoubben 3fd0c37d22 Review of 082: a store killed mid-conversation is an outage and a wrong password is not; one report holds the queue at most two minutes, then is let go loudly; shutdown does not wait out the pause 2026-09-22 13:34:04 +02:00
jschoubben 047830a6b5 A report the store cannot take yet is handed back to the broker, not acknowledged and lost (novox/hq issue 082) 2026-09-22 13:25:13 +02:00
jschoubben ff26ea959d Merge pull request 'Every machine was named twice over; only a machine on the private network is named (issue 079)' (#41) from multiple-fixes into main 2026-09-22 02:19:13 +02:00
jschoubben 5150c0a0f8 One predicate for the private network: the filter's accept set is the set the names and the resolver mean; the catalogue is read once for the three mesh-wide questions 2026-09-22 02:04:28 +02:00
jschoubben 6a64aba506 Only a machine on the private network is named: the names follow the resolver's rule, one predicate for both (novox/hq issue 079) 2026-09-22 01:41:13 +02:00
jschoubben 8152665298 The facts write the suffix the control plane composed the names with, handed down rather than written twice (review of issue 079); the fixture is keyed as production keys it 2026-09-22 01:27:50 +02:00
jschoubben b529c49cff A machine's names are not suffixed twice: the facts take the internal names the control plane hands them (novox/hq issue 079) 2026-09-22 01:13:28 +02:00
jschoubben 713b43799f Merge pull request 'Multiple fixes: a run-once step may name what it reads (ADR 0099); secret accept refuses an undeclared name (078)' (#40) from multiple-fixes into main 2026-09-21 23:58:47 +02:00
jschoubben 0d1f2a4152 Review: module issue's pre-check factored and tested; a pair delivery for a requirement the module keeps no secret for is refused; builder issue's usage says the module comes first 2026-09-21 23:58:36 +02:00
jschoubben 66332705cd module issue refuses a module with no broker own secret before making the account (issue 078) 2026-09-21 23:46:28 +02:00
jschoubben 35c5c2bb9b secret accept is refused for a name the module does not declare, and a pair delivery for a requirement or local it has not got (novox/hq issue 078) 2026-09-21 23:31:06 +02:00
jschoubben e4da83496f A run-once step may name what it reads: the pair run-once + restart-on is no longer refused (novox/hq ADR 0099) 2026-09-21 23:31:06 +02:00
jschoubben 52d39f0740 Merge pull request 'The control plane's recipe declares its base, and an undeclared FROM is refused (ADR 0097 live)' (#39) from multiple-fixes into main 2026-09-21 22:58:22 +02:00
jschoubben b3486270d1 The control plane's recipe starts FROM the base its manifest declares, and an undeclared base is refused
The mesh's own images declare theirs now, so the refusal ADR 0097 deferred is live.
The builder's own image and the examples take arguments with defaults; make builds
them, not the mesh.
2026-09-21 22:16:10 +02:00
jschoubben d7dab9c09f Merge pull request 'Multiple fixes: several secrets per module (069), ask a module's tool (049), copy an upstream image (046), declare a vendor image (064)' (#38) from multiple-fixes into main 2026-09-21 21:05:11 +02:00
jschoubben 9f3790dcda Review: one local name is still a local name; a local name is unique; recovery knows it; recipes read as instructions; a tag before a digest; ask fails at once when nothing serves
A secrets object with one local name delivered no file. Two requirements could share
a local name. secret recover and the export could not tell two locals apart. The
recipe check missed continued lines and read heredoc bodies as bases. repo:tag@digest
kept the tag in the repository. ask now publishes mandatory, so a tool nothing serves
is said at once rather than after the wait.
2026-09-21 21:03:22 +02:00
jschoubben e81f352979 An undeclared COPY --from is refused; an undeclared FROM is said, not yet refused
The mesh's own images start FROM a public base — the control plane's, the builder's,
the tool runtime's — and refusing those refuses genesis. They declare their bases
next; until then the base is named every build, with the remedy.
2026-09-21 20:48:00 +02:00
jschoubben 6f6e1244d4 A build declares the vendor image it stands on, and a recipe fetches nothing undeclared
build.on takes {arg, image@sha256:…} beside {arg, module, artifact}: the image is
copied into the mesh's registry before the build (ADR 0096) and the recipe reads the
copy from the argument. A FROM or COPY --from naming a registry image the manifest
did not declare is refused before the build, naming it and the remedy; stages,
declared arguments and scratch are not fetches (novox/hq 04-ISSUES/064, ADR 0097).
2026-09-21 20:45:36 +02:00
jschoubben 412599de9a An upstream image is copied between registries, never through a machine's image store
A published image is an index over several architectures; pulled, the runtime's
store keeps the index and refuses to push one platform out of it. The builder now
reads the index and every manifest it names over the registry API, with the
anonymous bearer token the public hub hands out, moves each blob by digest into the
mesh's registry, puts the manifests and then the index under the module's repository,
and pins the index. Genesis, with no registry to copy into, keeps the pull
(novox/hq 04-ISSUES/046, ADR 0096).
2026-09-21 20:42:30 +02:00
jschoubben 5049d201c6 A provider keeps one grant file per holder, with the local name in its id and path
The lab's vault refused a declaration naming two files with one identity: the
two secrets of one consumer. The holder's suffix is in the resource id and the path now.
2026-09-21 20:41:19 +02:00
jschoubben 3e5c010c5e A need is kept once per provision, consumer, local name and provider
Two consumers of one same-node provision produced two raw needs and, fanned out per
consumer, four — the same credential twice for each. Harmless, since a pair is one
row however often it is asked for, and wrong all the same.
2026-09-21 20:38:01 +02:00
jschoubben 76773dfbf5 Several secrets expand where the consumer is known, not on the first module to mention the provision
The lab's two-secrets consumer was given one credential and no file: the expansion
ran on the resolver's walk over names, on whichever module mentioned the provision
first, and the per-consumer pass copied that. It expands in that pass now, and a
test has two consumers of one provision, one keeping one file and one keeping two.
2026-09-21 20:36:19 +02:00
jschoubben 973cda5d76 ask: the control plane calls a module's tool and prints its answer
A module serves tools under an account scoped to exactly that, and nothing else in
the mesh held an account that could ask one. The control plane does: ask publishes
on the RPC exchange with a private reply queue bound under its own name, checks the
correlation, prints the answer, and exits non-zero for a tool that answered with an
error or a module that never answered (novox/hq 04-ISSUES/049, ADR 0095).
2026-09-21 20:34:03 +02:00
jschoubben 6ae4ae1dba A module may hold several secrets from one provider, each a pair of its own
secrets: maps a requirement to several files under local names. Each local name is
its own need, its own pair credential (the pair is keyed on it: migration 0027),
its own file on the consumer, its own holder at the provider (the identity with the
local name after it) and rotates apart from the others. The plain shape is
unchanged and every existing row is the credential it was (novox/hq 04-ISSUES/069,
ADR 0094).
2026-09-21 20:28:16 +02:00
jschoubben f3bfc11565 Merge pull request 'Multiple fixes: a stuck machine is said to be one (065), an undeclared mount is refused (026), an operator delivers a pair credential (070)' (#37) from feat/multiple-fixes into main 2026-09-21 19:23:33 +02:00
jschoubben 53a79a17a1 Review: a failure is the same by resource id, not by the host's words; bound files and /run/docker.sock are declared
The host's error text may carry a duration or a counter, and a resource looping on
it would never have read as stuck. The previous row is read and compared here.
Stuck needs a start to say. A container may mount the file a binding lands in; the
runtime socket is declared under both of its spellings; the catalogue-wide test
takes MESH_CATALOG.
2026-09-21 19:23:02 +02:00
jschoubben 396e05bb65 An operator delivers a pair credential, and the mesh never replaces it
secret accept grows --provider: the value is sealed to the consumer's node, the
provider's node and the operator's key, and the pair records origin 'accepted'.
An accepted pair is not remade when a key changes (the mesh does not hold the
value; the read is refused naming the remedy) and rotate refuses it (accepting a
new value is the rotation). The vault's third species has its entry
(novox/hq 04-ISSUES/070, ADR 0092).
2026-09-21 17:50:41 +02:00
jschoubben 537ad544d3 A container may not mount a path the module never declared — and three things declare one
Brought back from 53eb000, withdrawn because it refused the builder's mount of the
container runtime's socket. A path is declared as the module's own (a directory or
file resource, or where a secret, grant or contribution lands), as the operator's
(an accesses entry, ADR 0051), or as the machine's (a facility a declared capability
grants: container-runtime grants its socket). Every catalogue manifest passes, and
a test says so (novox/hq 04-ISSUES/026, ADR 0091).
2026-09-21 17:47:44 +02:00
jschoubben fcaad7271a A failure that repeats is said to be stuck
The mesh kept one report per machine, replaced, so a resource nothing can ever apply
looked like a failure that had just happened, every reconcile interval, for ever.
The row now keeps when the current failure began and how many reports in a row have
said it — the same outcome, refusal and failed resources; anything different starts
again and a clean apply clears it. Three make the machine stuck, and status says so
beside the failure, in words and in JSON (novox/hq 04-ISSUES/065, ADR 0090).
2026-09-21 17:43:24 +02:00
jschoubben 69d0b94395 Merge pull request 'The link knows a superseded report (issue 031)' (#36) from feat/migration-blockers into main 2026-09-21 13:48:43 +02:00
jschoubben 1ab0364705 The link knows a superseded report (issue 031) 2026-09-21 12:11:52 +02:00
jschoubben 153990348c Merge pull request 'A secret reaches a process as a file (ADR 0086, closes issue 041)' (#35) from feat/secret-not-in-environment into main 2026-09-21 11:59:02 +02:00
jschoubben 67de216160 The controller's own manifest reads its credentials from files too
The mesh-controller repository carries the manifest the mesh builds the
controller from; the catalogue's copy is what genesis registers. The two must
say the same thing, and the first rebuild from source proved they did not.
2026-09-21 10:33:22 +02:00
jschoubben 69bb0fcb67 A module names who its secret files belong to (secrets-owner)
The control plane runs as 65534 and crash-looped on permission denied the
first time its credentials were mounted as files the host wrote as root at
0600 — the env-file shape hid this because the daemon reads an env-file on
the host side. The composer now gives a module's secret files the owner the
manifest names.
2026-09-21 10:19:00 +02:00
jschoubben 4531f2244f A secret reaches a process as a file (ADR 0086)
The broker settings take a _FILE twin like the store connections; the
catalogue engine refuses a secret placeholder in a container's env and a
secret-carrying env-file unless the container says why with
secrets-in-environment, which stays in the catalogue and never reaches the
machine.
2026-09-21 10:10:33 +02:00
jschoubben 6b695c82d7 Merge pull request 'Secrets vault: operator key, a second seal on every secret, recovery and export' (#34) from feat/secrets-vault into main 2026-09-21 09:34:47 +02:00
jschoubben 77e6c1a684 Recoverable means sealed to the current operator key; recovery names the provider
From review: the export counted any operator-sealed row as recoverable, so a
secret sealed to a replaced key was reported as openable with the current one;
replacing the key counted orphans in one table of two; and a pair credential
held from two providers was recovered as whichever row came first. The export
now lists what the current key opens, what an earlier key opens, and what has
no copy; `secret recover` takes --provider and refuses ambiguity; files that
must not exist are created exclusively; one constructor builds the export for
the operator's file and the vault's disk alike.
2026-09-21 01:16:32 +02:00
jschoubben 565f144a20 A pair credential is sealed to the operator key too
The secret the vault provides a module is the credential of the consumer↔vault
pair, and so is every credential a provider grants; sealing only own secrets
to the operator left exactly those unrecoverable. Same column, same call; the
export and `secret recover` address a pair by consumer node, module and the
provision's name, and say which kind each entry is.
2026-09-21 00:36:16 +02:00
jschoubben e140ed5d0b An operator key, a second seal on every own secret, and the vault keeps the export
novox/hq ADR 0085, amended: the mesh's root secrets — the store's superuser,
the broker's administrator, every secret a module holds for itself — were
sealed to a node key and nothing else, so a lost node took them with it.
Now the mesh records an operator's public sealing key and seals every own
secret to it as well, minted or accepted. The private half is written once
by `operator key new` to a file the operator keeps off the mesh; the mesh
holds one more blob per secret that it cannot open.

`secret recover` opens a secret with that key, to a 0600 file, from the
store or from an export; `secret export` writes every operator-sealed copy
as ciphertext. A module that `keeps` (the vault) is handed that export as a
declared file on its own disk, so recovery survives the store.

Secrets made before the key exists have no operator copy and are said so —
the plaintext was discarded — until each is issued again.
2026-09-20 23:56:49 +02:00
jschoubben 1c2e94e1a0 Merge pull request 'The catalogue moves out, to novox/mesh-catalog' (#33) from chore/catalogue-moves-out into main 2026-09-20 22:13:37 +02:00
jschoubben 4ac12115ba The catalogue moves out, to novox/mesh-catalog
The modules the mesh runs were under examples/modules/, which framed
the real catalogue as illustrations of a control-plane package. They
are neither examples nor the control plane's — they are the mesh's own
catalogue, and they now live in their own repository (novox/mesh-catalog),
consumed as a build source like any other.

The engine that reads them stays here (internal/catalogue): the control
plane owns the manifest contract; the data does not belong beside it.

Removed with them: modules_test.go and parseall_test.go, which validated
the example manifests against the parser. That validation logically
follows the catalogue to mesh-catalog, but it imports internal/catalogue,
so re-homing it needs the parser exported from internal/ first — a
deliberate follow-up, not done here. Until then the pipeline is the gate,
and internal/catalogue's own inline tests still cover the parser.

Answers novox/hq ADR 0030's open tier-4 question — where the catalogue
lives — in favour of one flat mesh-catalog repository.

Claude-Session: https://claude.ai/code/session_01LrgweAeERJYBg88c5cKDzF
2026-09-20 22:12:51 +02:00
jschoubben 630eed82f3 Merge pull request 'The consistency cascade sends tolerantly and stops loudly (057 review)' (#32) from fix/cascade-is-tolerant into main 2026-09-20 13:41:55 +02:00
jschoubben d6ee77f17c The consistency cascade sends tolerantly and stops loudly (issue 057 review)
Two robustness fixes to the ADR 0083 cascade, from an adversarial review:

- It routed swept machines through sendTo, which is all-or-nothing — so
  one swept machine's compose error failed the operator's named push and
  skipped its --wait, the intolerance the main path exists to avoid
  (ADR 0066). It now composes them through composeEach, exactly as the
  named send does: a machine that cannot be worked out is a refusal in
  the final report, and the rest are still sent. composeEach's tolerance
  is already covered by TestOneUnresolvableNodeStillLetsTheRestBeSent.
- The fixed 4-round cap could stop a real cascade short in silence. The
  loop is now bounded by the node count (a node is flushed once and never
  revisited, so it cannot run longer) and says so if the guard is ever
  hit, rather than passing over an unfinished cascade quietly.

Scope is unchanged: a named push still flushes every machine left behind,
per ADR 0083 as accepted.
2026-09-20 13:27:12 +02:00
jschoubben 5dd2432b50 Merge pull request 'One push leaves the mesh consistent; the foundation's ports are forwarded (057, 063)' (#31) from fix/one-push-is-enough into main 2026-09-20 12:53:14 +02:00
jschoubben 97c076ea48 The one-push cascade compares against what was last sent (issue 057)
The first cut compared a before/after snapshot of the named push — but
the provision is minted at assign or module-issue, before push runs, so
by push time the provider is already behind with no delta to detect.
Fixed to flush machines whose declaration differs from what they were
last SENT (the same Waiting path --behind uses), which is the honest
meaning of 'one push leaves the mesh consistent' (ADR 0083). Verified
live on a kept two-node mesh: pushing the consumer populates the
provider's grant and the vhost is minted.
2026-09-18 02:37:26 +02:00
jschoubben 25e42b3ad6 The foundation's ports are forwarded, not only accepted on input (issue 063)
The broker's amqps port is opened from anywhere so a node can enrol
before it has an overlay address — but only in the input chain. The
broker is a published container port, so a cross-node dial is DNAT'd
and forwarded, never reaching input; it survived on the first
connection's conntrack entry and no more. Adopting the foundation's own
broker restarts it, dropping that entry, after which a joined node
could never receive another declaration. The forward chain now carries
the foundation ports too, from anywhere, matching their input rule.

Intermittent in the built-store-cross-node bed: it passed whenever the
broker did not happen to restart after the joined node first connected.
2026-09-18 02:19:34 +02:00
jschoubben f47b6e1c31 One push leaves the mesh consistent (issue 057)
A provision is minted while composing the consumer's node, and the
provider's grant list is a pure read of secrets already issued — so
pushing the consumer left the provider blind until somebody pushed it
again, with no signal to. A named push now captures what every machine
should be before composing, recomputes after, and sends the machines
whose declaration changed because of this push — by name, never
silently, converging over bounded rounds.
2026-09-18 02:05:55 +02:00
jschoubben 0b574f3972 Merge pull request 'The broker credential resolves the seat, not the hub (issue 059)' (#30) from fix/broker-address-resolves-the-seat into main 2026-09-18 01:12:49 +02:00
jschoubben 79a9e17df0 The broker credential resolves the mesh-broker seat, not the hub (issue 059)
An adversarial review of the 055 fix found it encoded the wrong invariants, latent while
every mesh keeps its broker on the hub. Now: the address is the overlay name of the node
ASSIGNED a module claiming the mesh-broker seat (the hub stands in only while nothing holds
the seat — genesis); "on the overlay" is what whereEveryoneIs answers (resolved the
networking module), not "has an address"; a portless genesis address defaults to 5671
instead of silently disabling the path; a second `overlay place --hub` is refused rather
than last-write-wins; and `overlay place` says that earlier credentials keep their old
address. A test now binds the controller's own module.json to its seat, so deleting the
claim fails the suite.

https://claude.ai/code/session_01D6qtiYU3P9jk3pnAXyAFyx
2026-09-18 01:02:26 +02:00
jschoubben 9c718025c0 Merge pull request 'The network carries the registry trust (ADR 0082, issues 042/048/062)' (#29) from feat/the-network-carries-registry-trust into main 2026-09-18 01:00:37 +02:00
jschoubben 98aea8b25c An artifact-store lookup failure refuses the compose
artifactStoreOnNetwork collapsed a failed inventory read into 'no
store', so a hiccup composed a declaration without the registry trust,
delivered by a push that reported success — and nothing recomposed the
machine until the next push. Seen once in three fresh runs of the
built-store-cross-node bed (run 11). Refused loudly instead: 'no store'
now only ever means the mesh has none.
2026-09-18 00:14:49 +02:00
jschoubben bc289cbe04 The restart-on rename reads both list shapes
A resource composed in code carries restart-on as []string; the rename
only read []any, so the overlay's registry-trust reload kept its bare
reference, pointed at nothing, and the runtime was never restarted —
the trust was on disk and not in the daemon, with every check passing.
Diagnosed on the built-store-cross-node bed, run 8 (issues 042/048).
2026-09-17 23:35:12 +02:00
jschoubben 0e9035d479 The network carries the registry trust (ADR 0082, issues 042/048)
Being on the private network is what grants a machine the right to pull from the mesh's
artifact store, so the module that puts a machine on the network writes the runtime's
trust — a merged /etc/docker/daemon.json naming the store's internal name under
insecure-registries, and a docker.service restart when that fact first lands. The registry
speaks plain HTTP because every path to it is already inside the overlay's encryption; the
provider is found, not configured — whichever module serves artifact-store, on whichever
machine holds it — and with no store on the network nothing is written, which is genesis.

https://claude.ai/code/session_01D6qtiYU3P9jk3pnAXyAFyx
2026-09-17 23:05:23 +02:00
jschoubben 39dc927a6f Merge pull request 'The controller's seat is mesh-controller; second-node assignment refused' (#28) from multi-node/foundation-seats into main 2026-09-17 02:16:05 +02:00
jschoubben fc4c5d1a60 The controller's seat is mesh-controller; a foundation module on a second node is refused
Renames the module's own claim the-controller -> mesh-controller (the seat is the server,
ADR 0079), and adds TestAFoundationModuleCannotBeRaisedOnASecondNode asserting each
foundation module's second assignment is refused with 'one per mesh'. Closes hq issue 056.

https://claude.ai/code/session_01D6qtiYU3P9jk3pnAXyAFyx
2026-09-17 02:13:26 +02:00
jschoubben 5718add8c3 Merge pull request 'A cross-node module reaches the broker by the hub's overlay name' (#27) from multi-node/broker-reaches-over-overlay into main 2026-09-17 01:44:41 +02:00
jschoubben afce2a5f41 A cross-node module reaches the broker by the hub's overlay name, not the public address
The broker credential handed to a module named the genesis MESH_BROKER_ADDRESS — the
broker's public endpoint. That is reachable from the control-node itself but not routed
to another node, whose firewall admits only the overlay (from:mesh); a consumer on a
joined node timed out fetching the broker's certificate and never connected.

brokerReachableAt returns the broker's address as the given node can reach it: a node on
the overlay gets the hub's `.internal` name (which every node resolves and the firewall
admits, the fingerprint pin making the host swap safe for TLS); a node not yet on the
overlay — at genesis, before any `overlay place`, when the builder's account is issued —
keeps the genesis address, so bring-up is unchanged. Both credential paths (module issue
and builder issue) use it. This is the reachability half of novox/hq issue 055.

https://claude.ai/code/session_01D6qtiYU3P9jk3pnAXyAFyx
2026-09-17 01:34:11 +02:00
jschoubben 68201c6478 Merge pull request 'Self-upgrade installer: SDK-by-version, mesh-controller rename, foundation adopted' (#26) from feat/a-bed-that-hands-over-nothing into main 2026-09-16 23:21:34 +02:00
jschoubben c3b88b9148 Rename mesh-control -> mesh-controller, substrate -> foundation
One name per thing, per the HQ glossary: the module/container/image/binary/repo
becomes mesh-controller, the seat the-controller, and the store+broker pair the
foundation (embedded base bundles, default template and example lock renamed with
their go:embed directives). No behaviour change — a pure vocabulary rename.

Claude-Session: https://claude.ai/code/session_01D6qtiYU3P9jk3pnAXyAFyx
2026-09-16 18:40:40 +02:00
jschoubben 366840fc0d push --wait: optionally block until a named node applies what it was sent
Opt-in (default 0, unchanged behaviour). A named push can wait until the node
reports it applied exactly this declaration, so an interactive push means "the
node is now what it was told". Not made the default: a push that recreates the
control plane would kill the waiting command running inside it.

Claude-Session: https://claude.ai/code/session_01D6qtiYU3P9jk3pnAXyAFyx
2026-09-16 13:23:01 +02:00
jschoubben 6a7e701629 Write the package credential only into a build that asks for it
A per-run .npmrc in every build context put a changing credential in COPY . . of
modules that resolve no mesh package — a non-deterministic image (a needless
rollout every build, which recreated the control plane) and a credential in a
build stage. Now it is written only for a package artifact or an image whose
Dockerfile names .npmrc.

Claude-Session: https://claude.ai/code/session_01D6qtiYU3P9jk3pnAXyAFyx
2026-09-16 12:45:18 +02:00
jschoubben ae55bd7bde Builds that need the registry run on the host network
An image build with an npm credential, and a package publish, join the host
network so 127.0.0.1 reaches the registry where the binding names it.

Claude-Session: https://claude.ai/code/session_01D6qtiYU3P9jk3pnAXyAFyx
2026-09-16 10:33:07 +02:00
jschoubben 4b9bc50aad The builder resolves the SDK from the mesh registry, and can publish packages
A new 'package' artifact kind builds a module's own code on a public base image
and publishes it to the mesh's package registry by version (hq ADR 0076) — the
SDK above all, which the toolchain is built from and so cannot be built in the
toolchain. The credential a build needs to resolve or publish packages is
rendered as an .npmrc (basic auth, hq ADR 0048) and given to an image build as a
buildkit secret, never a layer, so a token is not baked into the toolchain image.

Claude-Session: https://claude.ai/code/session_01D6qtiYU3P9jk3pnAXyAFyx
2026-09-16 10:27:26 +02:00
jschoubben 2fb700d61b Builder diagnostics go to stderr; stdout is the result alone
The logging added a line to stdout, and the genesis path parses the builder's
stdout as JSON — so the first log line broke the parse with "invalid character
'c'", the c from "[clone]". A build that had worked stopped working because of a
print statement.

The installer's runner captures stdout alone (cmd.Output), and the contract was
already stdout=result, stderr=everything else. The fix is to honour it: every
builder diagnostic — the step log, the per-command echo, the module path's own
lines — goes to stderr. Stdout carries only once.go's result JSON.

And a unit test now fails if any fmt.Print to stdout appears in the two builder
command files, except the three that belong there: the result, --version, and
--help. A guard, because this was invisible until a 20-minute run hit it, and the
same class of mistake should fail in milliseconds next time.

Claude-Session: https://claude.ai/code/session_01D6qtiYU3P9jk3pnAXyAFyx
2026-09-15 23:01:04 +02:00
jschoubben 9070d2502c The builder narrates every step, and every command it runs
A build was silent from clone to publish, so a build in progress, one that failed
quietly, and a request that never arrived all looked identical — which cost a long
diagnosis against a running mesh chasing "the handler never fired".

Now: the handler announces a request the instant it lands. Build logs each phase
— clone, commit, manifest, bases, each artifact starting and finishing with what
it produced, resolve, done — through a Log callback that is nil-safe, so the tests
that pass none still build. And the Command runner echoes every command before it
runs, with where and how long it took, because on a hang the last line is exactly
the command it is stuck inside: "git clone waiting on a network that will not
answer" rather than "the builder did nothing".

The unreadable-request path prints to stdout now too, not stderr, so it shows in
docker logs without splitting streams — the split is what hid it.

Claude-Session: https://claude.ai/code/session_01D6qtiYU3P9jk3pnAXyAFyx
2026-09-15 22:26:35 +02:00
jschoubben b8cacbaf4d What remains of names.go is the naming
The hosts-file writing moved to the node-names fact; what stays is the suffix
(configurable, MESH_INTERNAL_SUFFIX, defaulting to .internal which IANA reserved
for exactly this), a node's internal name, and the rule for what a node may be
called. Several things compose an internal name, and one of them writing the
suffix differently would be a name nothing answers to.

First attempt at this rewrote the file from memory and silently dropped the
configurable suffix. Restored from the original instead — deleting most of a
file is git surgery, not paraphrase.

Claude-Session: https://claude.ai/code/session_01D6qtiYU3P9jk3pnAXyAFyx
2026-09-15 21:33:20 +02:00
jschoubben fcdb065660 The mesh's knowledge is a fact a module asks for, not three modules
mesh-names, mesh-resolver and the names half of the overlay generators are gone.
They ran no software and could not be swapped for anything, which is the test of
whether something is a module at all — they existed because computed output
needed somewhere to live, and the control plane's only shape for output was a
module.

Now a module says where it wants what the mesh knows:

  facts: { node-zones: /etc/mesh-resolver/nodes.conf }

and is given a file, under its own name, applied and removed like anything else
it declares. Two facts exist: node-names (a hosts file — exact names) and
node-zones (every machine as a wildcard, *.homer.internal is homer). Asking for
a fact the mesh does not compute is refused naming what would have worked,
because a daemon that starts and reads a file nobody wrote is a worse way to
find out.

The names ride with the network now: wireguard's manifest asks for node-names
into /etc/hosts, because being on the private network is what gives a machine a
name. networking no longer requires name-resolution — names are not a provision,
and the module that answered it ran nothing.

One behaviour inverted, deliberately: choosing another VPN used to drag
WireGuard in anyway, because only WireGuard provided the addressing the names
module required — the node-scope claim existed to at least make that loud. With
names as a fact there is nothing to drag in: tailscale assigned means tailscale,
alone. The claim still catches two VPNs assigned explicitly.

And a machine the mesh cannot place is left out of both files rather than named
at nothing: a name resolving to nothing hangs a connection, where an unknown
name fails at once and says so. In practice that is only ever a token issued and
not yet used — a machine that has announced itself has an address.

Claude-Session: https://claude.ai/code/session_01D6qtiYU3P9jk3pnAXyAFyx
2026-09-15 21:31:18 +02:00
jschoubben 18ec632baa The example manifest follows the rename
Claude-Session: https://claude.ai/code/session_01D6qtiYU3P9jk3pnAXyAFyx
2026-09-15 20:51:00 +02:00
jschoubben 385bc5b9bf A module can be told which port it was given
The mesh assigns the machine-side port and a module does not choose one (ADR
0038). For a container that is invisible: the mesh rewrites ports into
assigned:wanted, the software binds the number it always bound, and the machine
publishes another.

A process has no such layer. It runs on the machine, there is nothing to rewrite,
and it binds whatever its configuration says. So every process bound the number
written in its own config, two modules declaring the same one would collide, and
the mesh's whole reason for assigning ports was defeated by the resource kind
that most needs it — introduced, by me, three commits ago.

So a module asks. ${port:8080} is "the machine-side port you gave me for the 8080
I said I listen on", written into its own configuration exactly as an address it
was bound to is.

Asking about a port it never declared is refused, and the refusal says what it
did declare: the module is asking about something the mesh has no opinion on, and
answering would put a guess into a configuration file as a port number. With
nothing assigned yet it is told what it asked for, so a mesh that has made no
assignment still composes something coherent rather than writing a zero.

Claude-Session: https://claude.ai/code/session_01D6qtiYU3P9jk3pnAXyAFyx
2026-09-15 13:20:01 +02:00
jschoubben e3748bc06f One module, several languages, each bundle packed alone
A module is one piece of software and may still carry a daemon in one language,
tools in another and a package in a third. The first cut compiled every bundle
into the toolchain's single output directory, so two of them would have
overwritten each other and then been packed together — one artifact containing
both, published twice.

So output is a property of the artifact, not of the toolchain, and the toolchain
says how it is told where to write rather than where it writes. Under a directory
named for the build rather than beside the source, so a pack never sweeps up the
module's own working files.

A second toolchain is declared so the multi-language path is exercised rather
than asserted — a list with one entry cannot fail the way a list with four will.

And the fake compiler in the tests now writes where it was TOLD to. One that
always wrote to a fixed place would have passed whether or not each artifact got
its own directory, which is the whole of what these tests are for.

Claude-Session: https://claude.ai/code/session_01D6qtiYU3P9jk3pnAXyAFyx
2026-09-15 02:09:06 +02:00
jschoubben 4b7bd1b3e2 A module says what it is written in, and needs no Dockerfile
The bundle recipe: the one that both builds and packs. An archive packs a
directory as it stands, so shipping compiled output meant compiling somewhere
first — which meant a Dockerfile repeating the same incantation in every module.
Two base arguments with no defaults, a working directory chosen so the SDK
resolves upward, the compiler invoked by absolute path because the usual symlink
is resolved away when the base image is assembled, a second stage, an environment
variable naming the entrypoints. Most of the catalogue is unconverted and that is
why; two conversions done in one session were each wrong twice with a working
example open in the next window.

A bundle says a language and a list of entrypoints. The mesh knows what the
language implies. Anything a module could override there it would be writing a
Dockerfile to override, so a toolchain is deliberately not configurable.

Declared rather than inferred, both of them: guessing the language from which
files are present makes a build depend on a directory listing, and guessing the
entrypoints makes it change meaning when somebody adds a helper.

A toolchain the mesh does not hold is refused before anything is compiled, naming
what to build first — the same treatment a missing base already gets, because it
is the same question and somebody can answer it. A language the mesh does not
build is refused saying what would have worked, since the author is usually one
word away.

The list of languages is closed and adding to it is a decision. Every language is
another implementation of the contracts every module shares, and those change
rarely and cascade when they do (ADR 0039) — a mesh whose SDKs disagree about the
envelope fails by ignoring messages rather than by failing to compile.

Claude-Session: https://claude.ai/code/session_01D6qtiYU3P9jk3pnAXyAFyx
2026-09-15 01:59:07 +02:00
jschoubben 3ae7b88c6d A catalogue asks for what it was not there to hear
Its event queue is durable, so a running catalogue misses nothing. What it cannot
have is what was announced before it first ran — and on a fresh mesh that is never
arbitrary: the shared base, the store the catalogue runs on, and the catalogue
itself are each necessarily built BEFORE a catalogue exists to hear about them.
The graph's foundation is the part it never sees.

So it says it is catching up, and the control plane re-announces what it
recorded, oldest first, marked as a replay. Oldest first because a graph is built
in the order things happened: registering a module that stands on a base before
the base would point an edge at a version nothing has seen, and the shape of a
fresh mesh guarantees the base is both first and the one that was missed.

The replayer hands announcements back rather than publishing them, because the
wire belongs to the link package and a replay building its own events could drift
from what the builder emits — the one thing it must match exactly, since the
catalogue has a single handler for both.

Its own queue and its own consumer: two consumers on one queue split its
messages, and a catch-up request going to whichever half was not listening is a
gap that looks like a working mesh.

Toward novox/hq 04-ISSUES/050.

Claude-Session: https://claude.ai/code/session_01D6qtiYU3P9jk3pnAXyAFyx
2026-09-15 01:26:58 +02:00
jschoubben 2b82872ac3 A build keeps what it was told
The builder announces a build with the resolved manifest, the path inside the
repository, and every artifact it stood on. The control plane received all of it
and kept none of it.

That was survivable while the catalogue heard the same announcement directly. It
stops being survivable the moment the catalogue was not there to hear it — which
on a fresh mesh is always, and always for the same modules: the shared base, the
store the catalogue runs on, and the catalogue itself are each necessarily built
BEFORE the catalogue exists to hear about them. The graph's foundation is the
part the graph never sees.

Replaying those builds needs what they said, not a summary. Without the manifest
there are no requires/provides edges; without `against` there are no build edges,
which are the ones that answer "a base moved, what must be rebuilt". A replay
carrying neither would restore the module list and leave the question the
catalogue exists for still wrong, while looking fixed.

Kept null rather than empty where a build predates this, so a replay can say it
is holding nothing instead of inventing an empty declaration for a module that
certainly had one. And `built_against`, not `built_on`: that column exists and
means the machine, which is a different fact about a different subject.

Toward novox/hq 04-ISSUES/050.

Claude-Session: https://claude.ai/code/session_01D6qtiYU3P9jk3pnAXyAFyx
2026-09-15 01:22:34 +02:00
jschoubben dda001d64b The firewall opens the port the mesh itself runs on
Rules are derived from what modules declare they listen on, and the substrate is
not a module. So the broker's port — the one every machine dials to enrol and to
receive every declaration it is ever sent — appeared in no ruleset the mesh has
ever generated.

Nothing caught it because a mesh of one never dials its own broker across the
network: the ruleset looks complete right up until a second machine tries to
join a firewalled anchor and is refused by the packet filter, during enrolment,
before the mesh can report anything about it. Assigning the firewall before
joining machines is both the natural order and the one that breaks.

It is a floor for the same reason ssh is. A machine nobody can reach cannot be
repaired; a machine the mesh cannot reach cannot be managed. Neither is a thing
any module asks for and neither may be derived away.

From anywhere rather than from the private network, deliberately: a node enrols
BEFORE it has an address on that network, so narrowing the rule to it would close
the door being knocked on.

The port is read from the broker this control plane was told about, so the
address handed out in a token and the port a machine must accept on stay one
fact. A mesh never told about a broker gets no such rule, rather than a broken
one — and cannot issue tokens either, which is where that surfaces.

Closes novox/hq 04-ISSUES/052.

Claude-Session: https://claude.ai/code/session_01D6qtiYU3P9jk3pnAXyAFyx
2026-09-15 00:54:31 +02:00
jschoubben 5062c36fc9 A binding answered on this very node still carries an address
Two sibling branches resolve a provision answered by the consumer's own machine.
The one for a node-scoped provider falls back to loopback when the machine is on
no private network, with a comment saying why and a test holding it. The one for
a mesh-scoped provider passed node.At straight through, and nothing noticed
because nothing had yet composed a host out of it.

The mesh's own artifact store is mesh-scoped and sits on the same machine as the
builder that pushes to it. Give the builder the address from its binding and it
gets MESH_REGISTRY=:5000 — a name with no host, written into its environment
without complaint. It surfaces much later as

  ":5000/mesh-tools/build" is not a valid repository/tag

which is a message about a tag for a fault in how a binding was resolved, on a
machine several steps from the decision.

A machine off the private network still reaches itself, which is what the
neighbouring branch already said. The test fails without the fix, showing the
empty address rather than only the symptom.

Claude-Session: https://claude.ai/code/session_01D6qtiYU3P9jk3pnAXyAFyx
2026-09-14 21:11:37 +02:00
jschoubben 30dfc9969c Merge pull request 'Only a thing built and never run is unassignable' (#25) from fix/only-a-build-input-is-unassignable into main 2026-09-14 17:33:02 +02:00
jschoubben 25d2fe1308 Only a thing built and never run is unassignable
The check read "declares no resources" as "runs nowhere", and those are not
the same. The private network declares no resources either — the control plane
computes them when it composes a machine's declaration — and it is assigned to
every machine that has to reach another one. Refusing it stopped a four-machine
bed at its first assignment.

The signal is narrower: it builds an artifact and places nothing. Made a
function of its own, because a judgement with a wrong answer this expensive
should be testable without a database — nothing guarded it, which is how it
shipped.
2026-09-14 17:32:43 +02:00
jschoubben 68e9a16c57 Merge pull request 'ssh is never left without a rule' (#24) from fix/ssh-is-never-left-with-no-rule into main 2026-09-14 17:16:31 +02:00
jschoubben 55d468798d ssh is never left without a rule
The floor allowed ssh from the mesh's addresses, and from everywhere on a
machine that faces outward. On a machine the mesh knows no addresses for it
emitted neither — so the chain dropped by default and ssh was simply shut.

That is the first machine anybody adopts: reached over the network, with the
port needed to fix it closed by the act of adopting it. Found by reading the
rules off a live machine rather than trusting the generator.
2026-09-14 16:53:39 +02:00
jschoubben 844dd09434 Merge pull request 'The firewall governs what is forwarded, and never closes ssh' (#23) from feat/the-firewall-covers-forwarded-traffic into main 2026-09-14 15:27:34 +02:00
jschoubben c8d8211385 The firewall governs what is forwarded, and never closes ssh
Two faults, opposite directions, both in issue 047.

There was no forward chain, on the reasoning that dropping there stops every
container the runtime allowed. The first half is true; the conclusion was not. A
published port is redirected and then forwarded, so it never reaches the input
chain — the firewall was silent about the ports most worth protecting. The way
through is the one the system being replaced already used: deny by default, then
allow the runtime's own networks explicitly. A forwarded rule matches what the
client originally asked for, because the destination has been rewritten by the
time the chain sees it.

And ssh is now a floor nothing derives. Every other line comes from what is
assigned, which is the point — but a mesh part-way through adopting a machine
has been assigned almost nothing, so what it computed was a chain that shut the
port used to fix it. From the mesh always; from outside on a machine that faces
outward, because that is the way in when the private network is what broke.

Rehearsed on three machines: a docker-published port declared mesh-only is now
reachable from inside the mesh and refused from outside. Before, it was
reachable from both.
2026-09-14 15:27:10 +02:00
jschoubben 260447282e Merge pull request 'A module that puts nothing on a machine cannot be assigned to one' (#22) from feat/a-module-that-runs-nowhere-is-not-assignable into main 2026-09-14 11:09:20 +02:00
jschoubben 95a9da8bdb A module that puts nothing on a machine cannot be assigned to one
The image every module in the scripted toolchain is compiled on top of is
registered as a module so the mesh can build, version and depend on it. It is
not one: nothing about it belongs on a machine. Assigning it succeeded, the
machine was sent a declaration containing nothing of it, and everything
reported success — the operator had said run this here and the mesh had agreed
to something it cannot do.

A module whose resources are worked out per node is asked about separately, so
it stays assignable, which is the point of it.
2026-09-14 11:08:32 +02:00
jschoubben 91db25389e Merge pull request 'An artifact may name which stage of its recipe to stop at' (#21) from feat/build-and-run-are-two-images into main 2026-09-14 02:02:28 +02:00
jschoubben 811b805c67 An artifact may name which stage of its recipe to stop at
One source, two images, deliberately different: a toolchain carries a compiler
and the image the same module runs in should not.
2026-09-14 01:57:07 +02:00
jschoubben cfe2816495 A module names the module its build stands on, not a copy of it
A fingerprint written into a recipe names one particular copy of the base — the
copy on whichever machine the person typing it was using. On any other mesh that
copy has never existed, so the build stops on its first line with a message
about an image nobody can look up. Three modules in the catalogue were in
exactly that state, and the line each of them replaced was equally dead.

A module now names the module and artifact instead, and the mesh answers with
what it holds. The builder is still a thing that clones, builds and answers: the
answer travels with the question, because only the mesh knows what it has.

A base the mesh has not built is refused before anything is built, naming which
module has to exist first.
2026-09-13 23:53:22 +02:00
jschoubben cb5108a864 Merge pull request 'A module is a repository and a path, the builder announces what it made, and the mesh acts on it' (#20) from feat/a-module-is-a-repository-and-a-path into main 2026-09-13 11:16:32 +02:00
jschoubben 21abd948aa Say that a module is unbuilt, rather than letting a machine call it malformed
A container naming an artifact is a module saying the mesh builds this. Until a
build publishes one there is nothing to run — and what reached the machine was
an unresolved field, which its language has no room for, so it refused the whole
declaration and reported that a container does not use "artifact". That reads
as a broken manifest. It is not broken, it is unbuilt, and only the mesh can
tell those apart.

Found by the four-machine bed, which assigns modules the mesh has not built.
2026-09-13 04:56:00 +02:00
jschoubben 3a08789d3c The control plane's declaration lives with the control plane
A module is a repository and a path within it, and the manifest sits at that
path. This one sat in the catalogue instead, so the thing that says what the
control plane is and the thing it is made of lived in different repositories
and could drift apart with nothing to notice.

It also names an artifact it builds rather than a placeholder digest somebody
fills in, which is what lets the mesh build its own control plane.
2026-09-13 03:27:10 +02:00
jschoubben 1a48214ef0 Flags may sit on either side of the repository 2026-09-13 03:26:02 +02:00
jschoubben 603be706fc The builder builds one module and stops, with no broker and no registry
This is how a mesh is raised: the installer carries this program and runs it
once, before anything exists, to produce the control plane from the same
repository and path every later rebuild will use. What raises the mesh is then
the same thing that maintains it, rather than a second mechanism exercised once
per new mesh — which is how often enough to rot.

With nowhere to publish, an image stays in the machine's own runtime and is
named by the digest of its own configuration: the same identity the installer
has always used for the image it carried.
2026-09-13 03:24:10 +02:00
jschoubben 588aa424e2 The mesh acts on what the catalogue decided a build meant
The builder says what it built and the catalogue decides whether that was an
upgrade. Only the control plane knows which machines run the thing, so it is
the one that acts — and what it does is a choice somebody recorded, not a
behaviour compiled in: record that they are behind, or send it, one machine at
a time or together.

Recording is the absence of an action rather than a second path: a machine not
running what the mesh would send it is already something the mesh reports.

Defaulted to recording. A mesh that rolls out everything it builds the moment
it builds it is reasonable to want and a bad thing to arrive by default — the
first module to inherit it would be the control plane, upgrading itself out
from under the push applying it.
2026-09-13 01:55:07 +02:00
jschoubben ca689073c4 A build says which machine made it, in the mesh's name for that machine
An event whose origin reads a container id names something no other module
can look up. The mesh already knows the answer, and a module's environment
file is a file resource, so ${machine:name} reaches it with no composer change.
2026-09-13 01:05:40 +02:00
jschoubben 3ffddff8ed The builder announces what it built, and what it was built on top of
Answering and announcing are different acts. The reply goes to whoever asked and
is correlated to their request; the announcement says to the whole mesh that a
module now exists at a commit, which is what the catalogue places in the module
graph (novox/hq ADR 0072). A build nobody asked for still has to be announced, or
the graph knows less than the registry does.

What it was built on top of is read out of the build's own inputs rather than
declared, because a declared list drifts from what the code actually uses
(ADR 0009). These are artifact references, which is what a build input names;
resolving them to module-versions is the catalogue's work, since it is what knows
which module-version published which artifact.

Events ride the topic exchange, not the direct one nodes speak over, so the
builder's account is granted both: it must be able to answer and to announce.
The envelope is the sdk's, reproduced exactly — a second shape would be a second
thing for consumers to handle, and they are written against the first.

Announcing is not allowed to fail a build. The work was done and was answered; a
build reported as failed because saying so failed is a lie about it.

Claude-Session: https://claude.ai/code/session_01D6qtiYU3P9jk3pnAXyAFyx
2026-09-13 00:57:41 +02:00
jschoubben f151de103f Build a module from a repository and a path within it
The builder cloned a repository and read the manifest at its root, which means one
repository per module. Nothing we have is shaped that way, so the builder could be
asked to build nothing that exists (novox/hq ADR 0069).

The path travels the whole way — named when asking, carried in the request, used
to read the manifest and as the context everything is produced from, echoed back
in the result, and recorded as part of where a module came from. Without that last
part the mesh could notice a module was behind its source and then be unable to
rebuild it, which is the worst of both.

A path climbing out of the clone is refused: a machine whose job is building other
people's repositories must not read whatever else is on its disk.

Claude-Session: https://claude.ai/code/session_01D6qtiYU3P9jk3pnAXyAFyx
2026-09-12 16:45:50 +02:00
jschoubben c4030947b0 The routing record is 0066, not 0056
0056 is 'the authority is the control plane, not a database'. A citation
pointing at the wrong decision is worse than none: it reads as corroboration.

Claude-Session: https://claude.ai/code/session_01LrgweAeERJYBg88c5cKDzF
2026-09-11 00:09:56 +02:00
jschoubben 522d8be925 Read a context's store connection from a file, not only from the environment
A store connection string carries a password, and the control plane took it
from MESH_STORE_<CONTEXT> — an environment variable, which is readable in
`docker inspect`, in the process's own /proc entry, and in whatever composed
it. Every other module in the catalogue is given secret material as a file the
mesh sealed to the machine and the host wrote.

That difference is what stopped the control plane from being an ordinary module
(novox/hq ADR 0067). A manifest can put a sealed value into a file's `content`
with ${secret:…}; it has no substitution into a container's `env` at all. So a
control-plane manifest could be written with the password in it, or without the
setting — neither honest. The fix is not to change the manifest format but to
let the control plane read what everything else reads: a file.

MESH_STORE_<CONTEXT>_FILE names one. Exactly one of the two may be set; both is
refused rather than settled by precedence, because whichever won, the other
would still read as the setting in force and the process would be writing to a
store nobody expects. Trailing whitespace is trimmed — a file written by a
person or by a filled-in placeholder ends in a newline, and a newline inside a
URL is rejected several layers from anything that could explain it. Leading
whitespace is left, being a mangled value rather than a habit.

The no-leak property is kept and extended: a file that cannot be read names its
path, never its contents, and the parse failure now names whichever source was
used because a variable name and a path are not the secret.

Claude-Session: https://claude.ai/code/session_01LrgweAeERJYBg88c5cKDzF
2026-09-10 23:40:37 +02:00
jschoubben c6029504e4 Merge branch 'fix/settings-assign-and-cli' into feat/adr-0056-compose-and-propagate 2026-09-10 21:17:46 +02:00
jschoubben 72f30e9c29 overlay: placing a node with nothing said no longer unplaces it
The sibling of node public-domain, and the worse one: a placement is three facts
declared together, so an invocation that said none of them took all three away —
the endpoint every other machine dials, the site, and the hub. A mesh whose hub
was placed that way has no paths left, at the moment somebody was trying to look
at it.

--nothing keeps the real case (a machine that roams and opens every path itself)
sayable, by name.

Claude-Session: https://claude.ai/code/session_01LrgweAeERJYBg88c5cKDzF
2026-09-10 21:17:09 +02:00