The next step after the schema: inventory now holds node records and enrolment tokens, and mesh-control has the commands to work with them. A token is issued for a node record, which is where re-enrolment gets decided -- what an identity binds to is settled when the token is made, not when it is presented, so the machine presenting one does not need to know whether it is joining or returning. What the token guarantees, each with a test confirmed to fail when the behaviour is removed: the secret is 256 random bits, shown once and stored only as a hash; it works exactly once; it stops working when it expires; issuing again for a node invalidates the outstanding one, because two live tokens are two machines able to join as the same node. Redemption is a single statement that finds and spends together, so eight concurrent attempts on one secret produce exactly one winner rather than a race between a check and a write. Refusals are deliberately identical for unknown, spent and expired. Somebody guessing must not learn which guess was a real token that had merely aged out. SHA-256 rather than a password hash, and that is a choice not a shortcut: the secret is high-entropy random, so there is nothing to guess and a slow hash would buy nothing while making every redemption expensive. It stops before what a node receives in exchange. What a machine presents afterwards to prove it is that node is not decided anywhere, and a migration is the most expensive place here to guess. So a token carries one of the four things ADR 0004 requires. The command prints the secret and then says exactly that -- the broker's address, its certificate fingerprint and the control plane's signing identity do not exist yet. Better than emitting something that looks complete and silently cannot be used.
7.4 KiB
mesh-control
Tier 2 of Novox Mesh — the control plane. Everything that needs to know about more than one node.
That is the whole test, and it draws the line the host cannot: the host applies and does not decide, because deciding needs knowledge one machine does not have. Which nodes should run the store, which peers belong in an overlay, whether a node has been unreachable for a week — nobody on a single machine can answer any of them.
The reasoning lives in novox/hq; this repository carries no argument that is not settled there.
What it is not
- Not the thing that changes machines. It decides; the host applies. It never reaches into a node except through the host, over the link, in a bounded vocabulary.
- Not a database. There is no mesh database. Each context owns its own store and nothing outside a context touches it — including nodes, which hold no credential to any of them.
- Not privileged. It has no more access to a machine than a declaration can express.
What exists today
One context of seven, and one of the things it will do.
inventory |
node records and enrolment tokens — built, as far as identity |
config, connectivity, provisioning, delivery, observability, identity |
not built |
| the interface every surface speaks to | not built; its shape is not decided |
mesh-control migrate bring each context's schema up to date
mesh-control node add <name> create a node record
mesh-control node list the nodes this mesh knows about
mesh-control token issue --node <name> a one-time right to join, for an existing record
mesh-control token issue --new <name> create the record and issue for it
mesh-control version what this binary is
migrate is step 3 of the substrate bootstrap — the step the first node cannot get past, run
against a database raised moments earlier from the bundle the host carries.
Tokens, and what they are missing
A token is a one-time right to join, issued for a node record — which is where re-enrolment is decided, since what an identity binds to is settled when the token is made rather than when it is presented.
What is built: the secret is 256 bits from the system's random source, shown once, and stored only as a hash, so a copy of this database is not a set of working credentials. It is usable exactly once and only before it expires, and both are read from the row rather than from a status something would have had to write. Issuing again for the same node invalidates the outstanding one — two live tokens are two machines able to join as the same node.
Redemption is a single statement that both finds a live token and spends it, so eight concurrent attempts on one secret produce exactly one winner. There is a test that runs them.
What a token is missing is three of its four parts. ADR 0004 requires the broker's address,
the fingerprint of its certificate, and the control plane's signing identity. None of the three
exists yet, so token issue prints the secret and says so, rather than producing something
that looks complete and cannot be used.
Where this stops, and why there
At identity. A node's own identity is the next thing needed and its cryptographic form is not decided anywhere: whether a node holds a keypair whose public half the mesh keeps, or something else. Modelling it would have meant guessing, in a migration — which is the most expensive place in this system to guess, because a schema that ran is finished and the only way back is another migration.
So the node table holds what a node record is — a name, when it was made, what the machine last reported about itself, when it was last heard from — and stops before what a node presents.
Reaching a store
A context is granted only what it exclusively owns. No shared writes, no read-only role on another context's store, and no connection string that reaches more than one.
That is a rule about credentials, so it is built as one. There is no mesh-wide connection setting and no way to ask for one:
MESH_STORE_INVENTORY=postgres://…/inventory
A process granted inventory holds that variable and no other. Reaching another context's store
is not a matter of restraint — it has no address for it and no credential to present. And it is
how the rule is checked: what a context can reach is visible in the declaration that runs it,
as the list of variables it was given.
Each context's database is named after the context. There is deliberately no database named for the mesh as a whole.
Migrations
Numbered, embedded in the binary, applied in order, each in a transaction with the row recording it. The applying is four lines; the rest is refusals, and the refusals are the point:
| it stops when | because |
|---|---|
| a migration that ran has since been edited | the database holds the old version, the repository holds the new one, and nothing holds the difference |
| a migration is numbered below one that already ran | usually two branches taking the same next number — applying it now runs the schema in an order nobody tested |
| two migrations share a number | order is the entire guarantee, and two files with one number have none |
| a file in the directory is not a valid migration name | a misnamed migration would otherwise never run and nothing would say so |
| a migration is empty | it records that something happened and changes nothing, which cannot be told from a mistake |
All of them stop rather than warn. At the moment any of them is true, nobody knows what the database contains, and there is no correct guess about a schema.
Running it again does nothing. Two copies running at once take an advisory lock, so a restart during a slow migration does not become two runners racing.
Building
make build the binary
make image the container image
make check gofmt, vet, and every test against a real PostgreSQL
make check raises a throwaway PostgreSQL in a container and takes it down afterwards, including
when the tests fail. Without one the tests that need a database skip and say so rather than
passing quietly — make test is the honest subset, not the gate.
Tests run against a real database rather than a fake because what is being tested is the database's behaviour: that DDL is transactional, that an advisory lock serialises, that a checksum mismatch is caught against a record PostgreSQL actually kept. A fake would assert that the fake behaves as expected.
Every test here has been confirmed to fail when the behaviour it defends is removed. Two did not, when first written, and both are now commented with what they were missing.
The image
FROM scratch, holding one statically linked binary and nothing else — no shell, no package
manager, no libc, no CA certificates.
Not a size optimisation. The bundle a host carries pins this image by digest, and it is fetched and run on a machine where no mesh exists to check anything and a person is expected to have read the bundle and believed it. Everything in the image is something that person would have to audit.
Where the reasoning lives
| what the control plane is | novox/hq ADR 0006 |
| what it takes to run one, and why Go | novox/hq ADR 0006 |
| a context owns its store, exclusively | novox/hq ADR 0008 |
| schema changes are numbered migrations | novox/hq ADR 0013 |
| a test defends a decision | novox/hq ADR 0017 |