Commit Graph
294 Commits
Author SHA1 Message Date
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