Record what four more pieces of the mesh became

Rotation and the provisioner contract; model access as a provision answered by
a record, with ADR 0024's other two gaps left as gaps; exposure, which closes
the open question about revoking a route; and the delivery loop, which closes
the gap ADR 0010 left when it replaced a pipeline with a comparison.
This commit is contained in:
2026-08-31 02:56:50 +02:00
parent 6b1c80b442
commit 0bc4b7774f
4 changed files with 258 additions and 2 deletions
+36 -2
View File
@@ -3,6 +3,7 @@ layer: to-be
status: designed status: designed
code: code:
- mesh-control internal/catalogue/filtering.go - mesh-control internal/catalogue/filtering.go
- mesh-control examples/route-proxy
- mesh-control internal/identity/authority.go - mesh-control internal/identity/authority.go
- mesh-host internal/identity/serving.go - mesh-host internal/identity/serving.go
- mesh-host internal/apply (the service that reflects a rule set) - mesh-host internal/apply (the service that reflects a rule set)
@@ -263,6 +264,38 @@ name rather than supplying nothing and receiving credentials.
**A workload on an unreachable node is proxied by a reachable one, across the overlay.** Which is **A workload on an unreachable node is proxied by a reachable one, across the overlay.** Which is
the case is a mesh-level fact, which is the fourth reason exposure is control-plane work. the case is a mesh-level fact, which is the fourth reason exposure is control-plane work.
### What was built
*2026-08-31.* Nothing new in the vocabulary, which was the claim and is now the fact: a route is a
provision, a proxy provides it, and a module that must be reachable requires it. The consumer
contributes the name it wants and the port it listens on; the proxy receives every consumer that
asked; the consumer is told what the provider serves, which is how it knows its own name.
**One field was missing, and it is the one anything reaching back needs.** A contribution now
carries **where the mesh says that machine is**. A database is reached *by* its consumer, so the
mesh never had to tell a provider where anybody was; a proxy is the other direction — it is told
to send traffic to a consumer and has to open a connection. Without it every provider implementing
a provision would have to know how the mesh names machines, which is a convention leaking into
every module.
**Exposure and filtering are different questions and a module answers both.** A workload says what
it listens on and who may reach it; separately, it says it wants a route. A module that asked for a
route and not for the port is unreachable by the proxy it just asked for — which the lab
demonstrates, because the machine is already filtering by the time this runs.
**Withdrawal, which was open above.** The file the proxy is given is the whole truth about who has
a route, so a proxy replaces its table rather than merging. Merging would keep serving a name whose
module was unassigned — and *a stale public name pointing at nothing fails more visibly than a
stale grant* is the reason it must not survive, not a reason to tolerate it.
**A name a proxy does not serve is refused by saying which it does.** A route that was withdrawn
and a name that never existed are different things, and a bare 404 makes an operator go and read
the mesh to tell them apart.
*Checked in the lab by a request to the name reaching the workload across the private network and
returning the workload's own answer, then by unassigning the module and requiring the same request
to stop working.*
## 4 — Filtering ## 4 — Filtering
**Derived from what is assigned here, and from the overlay's shape** — a node's open ports are a **Derived from what is assigned here, and from the overlay's shape** — a node's open ports are a
@@ -428,8 +461,9 @@ The list is worth having in one place, because it is most of the argument:
- **Renumbering the overlay.** Made *possible* by declaring the hub rather than inferring it from - **Renumbering the overlay.** Made *possible* by declaring the hub rather than inferring it from
an address, but no procedure exists, and a graph delivered node by node has an ordering problem an address, but no procedure exists, and a graph delivered node by node has an ordering problem
while it is half-applied. while it is half-applied.
- **Revoking a route** when a module is unassigned ([ADR 0007](../../02-DECISIONS/0007-connectivity.md)). - ~~**Revoking a route** when a module is unassigned.~~ **Resolved** 2026-08-31 — see §3. The file
A stale public name pointing at nothing fails more visibly than a stale grant. a proxy is given is the whole truth about who has a route, so a route does not outlive the module
that asked for it.
- **IPv6.** [ADR 0007](../../02-DECISIONS/0007-connectivity.md) makes - **IPv6.** [ADR 0007](../../02-DECISIONS/0007-connectivity.md) makes
it expressible; nothing here says the overlay or the resolver handle it. it expressible; nothing here says the overlay or the resolver handle it.
- **Reporting declared-versus-observed.** ADR 0007 makes the disagreement detectable and does not - **Reporting declared-versus-observed.** ADR 0007 makes the disagreement detectable and does not
@@ -151,6 +151,36 @@ the arrangement working: a build machine shares the runtime it was given rather
it sits on. **A module is cloned from the forge over a URL**, and "build this directory" is a it sits on. **A module is cloned from the forge over a URL**, and "build this directory" is a
convenience for a builder somebody started by hand. convenience for a builder somebody started by hand.
### And the loop is closed
*2026-08-31.* [ADR 0010](../../02-DECISIONS/0010-delivery.md) replaced a pipeline with a comparison
and named the risk: **losing the question "did my change go out?"**. The mesh could already answer
which modules were behind their source — and then a person read that list and retyped each
repository, which is a person being the loop, and the loop is the thing the pipeline was doing
before it was taken away.
`build --behind` is the other half, and it is the mirror of `push --behind`: the mesh knows what is
stale, so it builds it. The two forms are deliberately not combined — naming a repository and
asking which need building are different requests, and guessing which was meant would sometimes
build something nobody named.
**One failing does not stop the others**, for the same reason one broken module no longer blocks a
machine's whole declaration: a mesh where one bad repository holds back nine good ones is a mesh
where nobody dares add the tenth.
**Each is built from its own recorded ref**, not from the commit the mesh happened to notice.
Pinning to that would quietly turn a tracked branch into a pin — a change of meaning nobody asked
for, arrived at by an implementation detail.
**Building is not delivering, and the two stay separate.** A machine keeps running what it has
until it is told otherwise; the mesh changing its mind is not a machine acting on it, and
collapsing the two is how a mesh comes to report success for something that has not happened.
*Checked end to end: a commit, a build, a catalogue entry, and a machine that ends up running what
the source says — with both halves that make the answer trustworthy. It is still running the old
one until it is pushed, and it stops being reported as behind once it has caught up, because a
status that says "behind" for ever is one nobody reads.*
## What is kept ## What is kept
**Every result, including the failures.** A failed build that leaves no trace is indistinguishable **Every result, including the failures.** A failed build that leaves no trace is indistinguishable
@@ -0,0 +1,95 @@
---
layer: to-be
status: designed
code:
- mesh-control internal/inventory/secrets.go
- mesh-control cmd/mesh-control/rotate.go
- mesh-control examples/postgres-provisioner
updated: 2026-08-31
decisions:
- 02-DECISIONS/0001-mesh-brokers-nodes-host-agents-think.md
- 02-DECISIONS/0009-modules-and-the-graph.md
---
# 13 — Credentials, and moving them
*Written 2026-08-31, when rotation was built. The delivery half was already proven; this is the
half [ADR 0001](../../02-DECISIONS/0001-mesh-brokers-nodes-host-agents-think.md) records as
unowned, and it was **measurably false** in the system being replaced.*
## What went wrong before, precisely
`provision_ensure`, documented as *NEVER rotates an existing secret*, minted a new password on
every adoption and updated **only the provider's row**. Consumers on three nodes held dead
credentials for two days. Two rows for one provision were written 216 ms apart, so at most one
could have matched the live role. The mesh reported success throughout.
Three separate faults, and it is worth naming them apart because they have different fixes:
| | |
|---|---|
| **one credential, many holders** | rotating it was necessarily a fan-out, and nothing enumerated who held it |
| **the record moved and the consumers did not** | the change and the delivery were different acts, and only the first happened |
| **nothing said so** | the mesh could not tell a rotated credential from a working one, so nobody looked |
## What replaces it
**Every pair has its own credential.** A provision between one consumer and one provider is one
password, made once and kept. So rotating a machine's credential touches one role and leaves every
other consumer alone — and *who holds this* is a query rather than an assumption. That alone
removes the first fault: there is no shared secret to fan out.
**The change and the delivery are one command.** `rotate` discards the credential and sends both
ends, and it does the sending itself. Leaving that to whoever remembers is the second fault
exactly, and the interval in which it goes wrong is unbounded — two days, in the recorded case.
**It is all-or-nothing.** If any affected machine cannot be resolved, nothing is sent and the old
credential keeps working. A mesh that has not rotated is far better than one that has
half-rotated, and the difference is that the first is obvious.
**The window is stated rather than hidden.** A role's password changes on the provider and the file
changes on the consumer, and those cannot be simultaneous. So there is an interval in which a
consumer cannot authenticate, and the honest thing is to make it as short as the broker allows and
to say it exists. `status` names who is still behind.
## The provider makes it true, and the mesh cannot
The mesh generated the password, sealed it to the machine that must accept it, and **discarded the
plaintext** — so it cannot tell a database to start accepting it. Something on that machine reads
what the host wrote and makes it true.
That something is part of the module, not part of the control plane. **The control plane decides
and never touches a machine; a provisioner runs on the machine and touches it.** Two files, because
the mesh could not compose a document containing a value it does not have:
| | |
|---|---|
| a manifest | every consumer, what it asked for, and where its credential is |
| one file per consumer | that consumer's password, alone |
**Both, or neither works.** A provisioner given the passwords and not the manifest finds a
directory of unexplained secrets and reports that nothing has been granted — which is true, and
reads exactly like a credential that was never delivered. That has now happened once, here.
**The password a provisioner uses is itself a file the mesh wrote.** Passing it through the
environment needs a person in the middle of the one path that exists so there is not one, and puts
a superuser password where `docker inspect` prints it.
## How it is checked
Not by comparing two files. **Two ends holding a matching string proves they agree, not that either
is right** — the recorded fault produced two ends that agreed with each other and not with the
database.
So the check is three logins against a real PostgreSQL, from the consumer's own machine, over the
private network:
1. the delivered credential authenticates
2. after rotation, the new one authenticates
3. **the one that was rotated away does not**
The third is what makes it a rotation rather than an addition. Without it the check passes against
a provider that added a password and removed nothing.
**Not over loopback.** `pg_hba` trusts anything there, so every password looks correct — a
deliberately wrong one returned a row for an afternoon before that was noticed.
+97
View File
@@ -0,0 +1,97 @@
---
layer: to-be
status: designed
code:
- mesh-control internal/licences
- mesh-control cmd/mesh-control/licence.go
updated: 2026-08-31
decisions:
- 02-DECISIONS/0024-model-access-is-a-provision.md
- 02-DECISIONS/0009-modules-and-the-graph.md
---
# 14 — Model access
*[ADR 0024](../../02-DECISIONS/0024-model-access-is-a-provision.md) decided it and listed four
things the mesh did not have. Written 2026-08-31, when two of them were built. **The other two are
still gaps and are still written as gaps** — the record's own warning is that pretending otherwise
is how a plan becomes a surprise.*
## What was built
**A licence is a record, and the first provision no machine answers.** Everything else the mesh
brokers is answered by something running on a node. A hosted model is on nobody's machine and is
reached over the public internet, so the rule that refuses two ends sharing no private network —
correct everywhere else — must not apply to it. A machine on no private network at all can hold a
licence, and that is not a special case to remember: it falls out of the answer not being a
machine.
**The name is the operator's.** *The personal account*, *the organisation's account*. Those are
names a person uses, and the mesh uses them too, because the whole point is saying **which one** a
consumer uses — and an anonymous credential hanging off a provider cannot be said. Many to many,
so deliberately **not a claim**: two machines sharing an account is the ordinary case rather than
a collision.
**One provision name for all of them.** A module requires `model-access`, never `anthropic`. A
module that named a provider could not be moved onto a model the mesh runs itself without editing
it — and moving it is the point.
**A model in a machine's own set answers it locally**, and no record is consulted. That is what
makes *the mesh's own model* an ordinary answer rather than a parallel arrangement.
### Accept: taking a value the mesh did not make
Every other credential here the mesh generated, sealed to both ends and discarded. An API key
arrives from a person, and the missing verb was *accept*: **take a value, seal it to each holder,
discard the plaintext.** A mesh that kept operator-supplied keys readably is the arrangement this
project measured and rejected.
**It seals to the holders that exist at that moment**, and this has a consequence that must be
said out loud rather than discovered:
> A consumer put on a licence *after* the key was supplied has no key, and **the mesh cannot make
> one** — it discarded the only copy.
So that state is reported at every point a person could meet it: when the consumer is put on the
licence, in `licence list`, and — decisively — **the declaration is refused** rather than written
without the file. A machine that resolves cleanly and receives nothing fails later, somewhere that
names neither the licence nor the mesh.
**A key is read from a file or standard input, never an argument.** A key on a command line is a
key in shell history and in every process listing taken while it ran. It is never echoed back:
what is stored is unreadable by whoever holds it, the control plane included, and printing it
would put the one copy that matters on a terminal.
## Refusing is felt, and that is the design working
ADR 0024 predicted it: *a mesh holding three ways to reach a model refuses every consumer that has
not said which — which is correct and is a great deal of saying-which the first time.*
It is correct, and correct is not the same as usable. So the refusal names **the candidates and
the exact command**. The difference between a mesh that refuses helpfully and one that merely
refuses is whether anybody can act on it without going and reading something else.
## Still gaps
Unchanged from [ADR 0024](../../02-DECISIONS/0024-model-access-is-a-provision.md), and deliberately
not half-built:
**A consumer that is not a machine.** *This worker uses that licence* is a binding to an agent, not
to a node. What is delivered still lands on a machine; what is **chosen** is chosen per agent, and
the provisions model has no consumer identity other than a node. What exists today is per module
per machine, which is a step toward it and is not it.
**Switching is a reaction, not a declaration.** A licence that hits its limit and must be swapped is
a response to something observed. Expressing it as a declaration would make the declaration mean
*whatever is working right now*, which is not a thing anybody declared. It belongs with
observability, changing a binding — and the binding is then declared as usual. **Saying this
plainly is what stops the declaration language growing a conditional**, and nothing built here
grew one.
## How it is checked
In the lab, on real machines, in the order a person would meet it: a consumer is refused with both
candidates named; put on one and still refused because no key exists; the key is given on standard
input and not echoed; the public half arrives saying it came from a record rather than a machine;
the key arrives readable only by that machine — and it is **nowhere in the control plane's own
database**, nor in anything that crossed the broker.