ADRs 0138 and 0139: an assignment binds an endpoint and says how far it reaches, and a network is forwarded because a module declared it

Both follow from the same rule the mesh is built on — a node's configuration is
composed from the modules assigned to it. Reach was settled separately by the
filter, the proxy's names and the certificate authority, so "this must not be
public" could not be written; it becomes one value on the assignment that all
three read. And the forward chain consulted two constants plus a typed list
although modules already declare their networks; it now forwards what they
declared, with the host rendering the addresses it allocated.
This commit is contained in:
2026-09-28 23:03:32 +02:00
parent 2126e7b2cb
commit 14ff89fa40
6 changed files with 398 additions and 7 deletions
+93 -1
View File
@@ -7,8 +7,10 @@ code:
- mesh-controller internal/identity/authority.go
- mesh-host internal/identity/serving.go
- mesh-host internal/apply (the service that reflects a rule set)
updated: 2026-09-27
updated: 2026-09-28
decisions:
- 02-DECISIONS/0138-an-assignment-binds-an-endpoint-and-says-how-far-it-reaches.md
- 02-DECISIONS/0139-a-network-is-forwarded-because-a-module-declared-it.md
- 02-DECISIONS/0104-a-provision-may-be-answered-by-an-adapter-to-the-predecessor.md
- 02-DECISIONS/0106-the-bus-is-nats.md
- 02-DECISIONS/0105-the-mesh-adopts-the-predecessors-tunnel-in-place.md
@@ -625,6 +627,42 @@ the found firewall reloads and reachable from a container on the node, that a ma
enrols through the openings before and after a reload and a reboot, and that after the flip the
declared port is open and the undeclared one closed.
### The networks it forwards are the ones its modules declared
*2026-09-28, preparing the control-node's convergence —
[issue 141](../../04-ISSUES/141-the-forward-chain-does-not-follow-the-modules/00-report.md), settled by
[ADR 0139](../../02-DECISIONS/0139-a-network-is-forwarded-because-a-module-declared-it.md).*
The forward chain denies by default, so a machine's own guests have to be allowed back in. Until now
that was two ranges named in the control plane and, since
[ADR 0137](../../02-DECISIONS/0137-a-machine-says-which-networks-it-routes.md), a list a machine could
add to. On the machine measured here, six of its container networks fell outside those ranges — and
four of the six were networks the mesh's own modules had declared and the host had created, while two
were the predecessor's leftovers. A range wide enough to keep the four keeps the two: a filter widened
by hand to protect what should not be there.
**A network is forwarded because a module declared it.** The forward chain forwards the networks of the
modules assigned to that node and, by default, nothing else; a module unassigned stops being forwarded
at the next reconcile. The control plane says which networks, by name, and the host — which created
them — renders their addresses, because the runtime allocates the subnet and the host is what knows it.
That keeps §4's shape and this document's: the mesh decides, the host applies.
**This is derivation from the declaration, not from the machine.** 0137 rejected reading the machine,
for two reasons that do not apply here: a network created between two declarations is already named in
the one that asked for it, and a network nobody declared is never forwarded however it appeared. What
0137's mechanism keeps is the case it was right for — guests no module declares, a test bed's range —
added to the derived set and never replacing it.
The runtime's own default bridge, which a container attaches to when it names no module network, is the
runtime's and not a module's, so the host renders it from what the runtime reports. With that, no range
is named in the control plane at all.
*How it is checked:* a node with two modules declaring networks renders rules for exactly those two and
none for a third present on the machine that nothing declared — which fails against forwarding by
range, and is how it was written; unassigning one removes its rule at the next reconcile; the default
bridge is asserted for a runtime whose bridge is somewhere other than the old constant named; and the
guests of a declared network keep address and name service, per chain body.
## 5 — Certificates
**Two authorities, kept separate on purpose.**
@@ -710,6 +748,60 @@ that verifies against the internal root and nothing else — which cannot succee
first reached the name to certify it*
([ADR 0066](../../02-DECISIONS/0066-public-routing-is-name-agnostic.md)).
## 6 — One statement behind exposure, filtering and certificates
*2026-09-28, preparing the control-node's convergence —
[issue 140](../../04-ISSUES/140-an-endpoints-reach-is-not-declared/00-report.md), settled by
[ADR 0138](../../02-DECISIONS/0138-an-assignment-binds-an-endpoint-and-says-how-far-it-reaches.md).*
The three sections above each decide, independently, how far a service reaches. §3 composes a name
from a label and the node's domain. §4 opens a port to the source a listen named. §5 certifies the
names that exist, from whichever authority the proxy holds. Each is coherent on its own, and together
they mean **reachability is never stated anywhere** — it is the sum of three derivations, and a sum is
not something anyone can review or refuse.
What that costs, measured: an identity provider holding a public certificate valid 90 days and an
internal one valid 24 hours, neither asked for by any assignment, because both names existed and a
proxy certifies what it serves. And an endpoint that is not routed — git over ssh — which can be
spoken about only in the filter's vocabulary, so *this must be reachable from outside* is a setting
exactly one mechanism reads.
**An endpoint is the thing that was missing.** A module declares named endpoints: one port it serves,
what it is for, and what it would serve that to absent any instruction. A route contribution names an
endpoint rather than repeating a port number. An assignment — which is where a module's configuration
lives ([ADR 0046](../../02-DECISIONS/0046-a-module-configuration-is-its-assignments-not-its-manifest.md))
— then binds each endpoint to a machine port and says how far it reaches.
One value, three readers:
| reach | the filter opens | the proxy serves | the certificate comes from |
|---|---|---|---|
| `internal` | the machine port, to the private network | the internal name | the mesh's own authority |
| `public` | the machine port, to anywhere | the public name | the public authority |
| `both` | the machine port, to anywhere | both names | each name's own authority |
**An endpoint that is not routed is reached and never named.** No route contribution means no name is
composed and no certificate requested, while the filter still acts on it. That is the case the model
could not express at all, and it is the ordinary case for anything that is not HTTP.
**Nothing moves until an assignment says so.** An endpoint whose assignment is silent keeps the
default its manifest states, so every machine already converged renders exactly as it does today —
the same property §4 needed when a machine gained a way to say which networks it routes.
This is what the certificate questions were waiting for. Which authority signs a name, whether a name
may appear in a public issuance log, and what must be trusted where are all answerable once an
endpoint says whether it is internal — and unanswerable while the proxy decides by composing every
name it can. It is also the fact
[issue 139](../../04-ISSUES/139-an-internal-route-name-resolves-to-the-consumers-node/00-report.md)
needs: an internal name should be composed from the machine serving the endpoint, which is the
assignment that bound it.
**How it is checked** is stated with the decision: one module with two endpoints of differing reach
asserted per chain body; the names and the certificate requests following the reach and failing
against today's behaviour, where both are always composed; an unrouted endpoint filtered and never
named; an assignment naming an endpoint the module does not declare refused where it is said; and a
silent assignment rendering byte-identically to today.
## What this removes
The list is worth having in one place, because it is most of the argument: