Compare commits

..
Author SHA1 Message Date
mesh-admin 61dc90e508 Merge pull request 'Issue 142: the host is the one thing the mesh does not deliver' (#172) from issue/142-the-host-is-not-delivered into main 2026-09-28 22:03:58 +00:00
jschoubben a7cf5c0c1b Issue 142: the host is the one thing the mesh does not deliver
A host change merged yesterday reached no machine without a person copying a
file. The host is not a build target, no declaration delivers it, and the half
that recovers from a bad host — noticing the executable changed, a known-good
record, a launcher that rolls back — is written, tested and called by nothing.
All four machines run a byte-identical hand-copied binary that no package owns
and no record names, so nothing can say a machine is behind.

Found because ADR 0140 needs the machine to report a new fact, and merging that
could not roll it out.
2026-09-29 00:03:41 +02:00
mesh-admin c6f86ae935 Merge pull request 'ADR 0140: the filter constrains what arrives from outside' (#171) from decision/0140-filter-constrains-what-arrives-from-outside into main 2026-09-28 21:32:10 +00:00
jschoubben 1aeb4fe8d8 ADR 0140: the filter constrains what arrives from outside, and says nothing about a machine's own guests
Reading a converged machine's rendered rules showed the cause: the chain blocks
everything passing through and then allows the machine's own containers back by
listing their address ranges. 0137 made that list typeable and 0139 tried to
generate it; both refined a list that should not exist, because the mesh has no
position on a container reaching outward. Constrain what arrives from outside,
allow what did not, and let the machine report which links face outside — one
fact instead of a list. Ports keep following the modules unchanged.

The records check now allows one record to supersede several, and stops
requiring a withdrawn record's own citations to be live.
2026-09-28 23:31:50 +02:00
mesh-admin 08108b569d Merge pull request 'ADRs 0138 and 0139: an endpoint's reach, and networks forwarded because a module declared them' (#170) from decision/0138-endpoint-reach-and-0139-declared-networks into main 2026-09-28 21:03:49 +00:00
jschoubben 14ff89fa40 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.
2026-09-28 23:03:32 +02:00
mesh-admin 2126e7b2cb Merge pull request 'Issues 140 and 141: an endpoint's reach, and a forward chain that does not follow the modules' (#169) from issue/140-endpoint-reach-and-141-forward-chain into main 2026-09-28 20:58:03 +00:00
jschoubben dcdfcf104e Issues 140 and 141: an endpoint's reach is declared nowhere, and the forward chain follows constants instead of the modules
Found preparing the control-node's convergence. Reach is settled independently by
the filter, the proxy's names and the certificate authority, so "this must not be
public" cannot be written and a public certificate is obtained regardless. And the
forward chain allows two hardcoded ranges plus a typed list, though the mesh
already knows which networks exist because its own modules declared them — a range
wide enough to keep four of them would have forwarded two predecessor leftovers too.
2026-09-28 22:57:38 +02:00
mesh-admin d23ace1646 Merge pull request 'Issues 138 and 139' (#168) from issue/138-the-uplink-seat-and-139-an-internal-route-name into main 2026-09-28 19:46:22 +00:00
jschoubben 5dbde0b13a Issues 138 and 139: a seat with interchangeable holders that are not, and an internal route name that resolves to the wrong machine 2026-09-28 21:46:20 +02:00
mesh-admin db3868e2b0 Merge pull request 'ADR 0137: a machine says which networks it routes' (#167) from decision/0137-a-machine-says-which-networks-it-routes into main 2026-09-28 19:31:03 +00:00
jschoubben 05039c4f10 ADR 0137: a machine says which networks it routes, and issue 137 is how that was found 2026-09-28 21:31:01 +02:00
mesh-admin b7bf601ca4 Merge pull request 'Issue 136: a module may name a program the machine does not have' (#166) from issue/136-a-module-may-name-a-program-the-machine-lacks into main 2026-09-28 18:50:37 +00:00
13 changed files with 1099 additions and 4 deletions
+16 -3
View File
@@ -164,6 +164,12 @@ def check_rests_on(failures, records):
# decision is exactly what as-is is for." # decision is exactly what as-is is for."
if rel(path).startswith("03-DESIGN/00-as-is/"): if rel(path).startswith("03-DESIGN/00-as-is/"):
continue continue
# A withdrawn record's citations are history. It instructs nobody -- every reader
# is sent to its superseder -- so what it was built on may itself be withdrawn.
# Refusing that would mean rewriting the lineage of a record whose reasoning is
# the thing the immutability rule protects.
if frontmatter(read(path)).get("status") == "superseded":
continue
# An extension that supersedes legitimately names what it replaced. # An extension that supersedes legitimately names what it replaced.
this = ADR_FILE.match(os.path.basename(path)) this = ADR_FILE.match(os.path.basename(path))
supersedes = records[number]["front"].get("superseded-by", "") supersedes = records[number]["front"].get("superseded-by", "")
@@ -241,13 +247,20 @@ def check_supersession_symmetry(failures, records):
failures.add("supersession", rel(record["path"]), f"superseder does not exist: {by}") failures.add("supersession", rel(record["path"]), f"superseder does not exist: {by}")
continue continue
other = records[match.group(1)] other = records[match.group(1)]
claims = os.path.basename(str(other["front"].get("supersedes", ""))) # `supersedes:` may name one record or several. One decision replacing two is a real
if claims != record["name"]: # situation -- two records that built and refined the same wrong mechanism are withdrawn
# by the one record that removes it -- and a check that allows only one would force
# either a chain of pro-forma records or an unmarked supersession.
claimed = other["front"].get("supersedes", "")
if isinstance(claimed, str):
claimed = [claimed] if claimed else []
claims = [os.path.basename(str(entry)) for entry in claimed]
if record["name"] not in claims:
failures.add( failures.add(
"supersession", "supersession",
rel(other["path"]), rel(other["path"]),
f"ADR {number} says this supersedes it; this record does not say so " f"ADR {number} says this supersedes it; this record does not say so "
f"(supersedes: {claims or 'absent'})", f"(supersedes: {', '.join(claims) or 'absent'})",
) )
@@ -0,0 +1,117 @@
---
topic: what runs on it
status: superseded
date: 2026-09-28
deciders: jochen
extends: 02-DECISIONS/0100-a-node-in-use-is-adopted-before-it-is-converged.md
reconstructed: false
superseded-by: 02-DECISIONS/0140-the-filter-constrains-what-arrives-from-outside.md
---
# 137. A machine says which networks it routes
## Context
The filter the mesh derives denies forwarding by default, because without a forward chain it says
nothing about a container's published port
([ADR 0100](0100-a-node-in-use-is-adopted-before-it-is-converged.md), [issue 047](../04-ISSUES/047-the-firewall-does-not-cover-published-container-ports/00-report.md)).
To keep a machine's own containers working it then allows two ranges: the container runtime's
default bridge pool, and the pool its compose files are given. Those two are named in the
controller's code, with a comment saying what the gap is:
> A machine whose runtime is configured with something else needs this to say so — which is a thing
> the mesh cannot derive and a reason this list is named here rather than computed.
**There was no way to say so.** The list was a constant. A machine whose guests live anywhere else
was filtered by a rule that looked deliberate and was a guess.
**Measured, on the day a workstation was converged.** Flipping it cut egress for five of its
container networks at once, and for every network its test beds create — the beds allocate a fresh
range per run, from a pool neither default covers. Nothing reported a fault. The containers could
not reach anything, the machine went on reporting that it had applied what it was told, and the
converge preview had said nothing about it either, because the preview lists what *listens* and
routing is not a listener.
**And two questions, not one.** A guest also asks its host for an address and for names. Both arrive
at the input chain, where nothing declared them, so denying by default left the guests of a routed
network with no address and no resolution — which is not a closed port but a network that does not
function, asked for by this machine's own guest.
**Why the machine cannot simply be read.** A test bed creates its bridge while it runs, between one
declaration and the next, so a filter derived from what the machine last reported would be correct
only for the networks that already existed when it was composed. A declared range covers the ones
that do not exist yet.
## Decision
**A machine says which networks it routes for what it hosts, and the filter forwards them.** A
node-level fact, beside the node's public domain
([ADR 0066](0066-public-routing-is-name-agnostic.md)) and for the same reason: the
machine routes them, and the module that loads the filter holds a seat and may be replaced.
**Added to the runtime's defaults, never replacing them.** A machine that names one range has not
stopped hosting whatever was already on the runtime's own pools, and replacing would trade one
silent breakage for another.
**Their guests keep address and name service.** For a network that was named, the input chain admits
that network's own DHCP and DNS, and nothing else: everything else a guest might want from its host
is a port somebody declares, like every other port on this machine.
**Said in CIDR form and checked when it is said.** An entry that does not parse is a line nftables
refuses, and a refused ruleset is a machine filtering nothing while its unit reports a fault — so
the refusal happens where a person can read it, not on the machine.
**A machine that says nothing is filtered exactly as before.** Every machine already converged is
untouched by this.
## Options considered
1. **Leave it constant and edit the code per installation.** Rejected: the value is a property of
one machine, the code is the whole mesh's, and the two ranges as they stand describe a machine
whose runtime was left at its defaults. It is also how this got here.
2. **Derive it from what the machine reports.** Rejected as insufficient, not as wrong: it cannot
cover a network created between two declarations, which is precisely the case that was broken. It
would also make the filter follow whatever appeared on the machine, which is a firewall that
widens itself.
3. **A per-node setting on the module that loads the filter.** Rejected: the machine routes the
networks. The filter module holds a node-scoped seat and is meant to be replaceable, and a
replacement must not lose the machine's own truth.
4. **Replace the defaults with what is said.** Rejected: see the decision. The first machine to name
its bed range would lose its containers.
5. **Admit all input from a routed network, not only address and name service.** Rejected: that is
every port on the machine open to anything it hosts, which is the derivation abandoned.
## Consequences
**The converge preview says what a machine routes**, including when it routes nothing but the
defaults, with the command that changes it. The preview's own sentence about traffic it cannot
preview stays, because a tunnel and the found firewall's NAT are still not previewable.
**A machine whose guests are already broken by an earlier flip is fixed by saying its networks and
pushing**, with no flip to undo.
**The list is one more thing that can be wrong and stale.** A range removed from the machine and
left here keeps forwarding for a network that no longer exists, which admits nothing, because there
is no guest on it to admit. That is the safe direction of being out of date.
## How this is checked
- **What a machine says it routes is forwarded, and its guests keep address and name service.** A
test renders a ruleset for a machine that names one range and asserts both chains, per chain body
so a line in the wrong chain cannot pass it. It fails against the previous behaviour, which is how
it was written.
- **The runtime's own defaults survive naming a range.** Asserted in the same test.
- **A machine that names nothing renders byte-identically to one that names nil**, so every machine
already behind this filter is untouched.
- **Each family is matched in its own syntax.** A test with one v4 and one v6 network asserts
`ip saddr` and `ip6 saddr`, because one set holding both is a syntax error and a ruleset that does
not load is a machine filtering nothing.
- **An entry that is not a network is refused where it is said**, by the parse in the setter.
## References
- [ADR 0100](0100-a-node-in-use-is-adopted-before-it-is-converged.md) — the derived filter this completes
- [ADR 0066](0066-public-routing-is-name-agnostic.md) — the precedent for a node-level fact
- [issue 047](../04-ISSUES/047-the-firewall-does-not-cover-published-container-ports/00-report.md) — why there is a forward chain at all
- [issue 137](../04-ISSUES/137-converging-a-machine-cut-off-its-own-guests/00-report.md) — the measurement that produced this
- mesh-controller `internal/catalogue/filtering.go` — the constant whose own comment named this gap
@@ -0,0 +1,154 @@
---
topic: what runs on it
status: accepted
date: 2026-09-28
deciders: jochen
reconstructed: false
extends: 02-DECISIONS/0045-a-machine-firewall-is-the-sum-of-what-it-listens-on.md
---
# 138. An assignment binds an endpoint and says how far it reaches
## Context
[ADR 0045](0045-a-machine-firewall-is-the-sum-of-what-it-listens-on.md) settled that a machine's
packet filter is derived from what its modules declare they listen on, and that the `from` of a
listen "is the whole of public-versus-internal". That was true of the packet filter, and it turned
out to be true of nothing else.
Reachability is now settled three times, in three places, by three mechanisms that cannot disagree
out loud ([issue 140](../04-ISSUES/140-an-endpoints-reach-is-not-declared/00-report.md)):
- **The filter** reads a listen's source, and a per-node setting may override it. That setting has
exactly one caller in the control plane — the function that builds the node's rules.
- **The names** come from a route contribution, which names a label and a port and says nothing
about reach. The reverse proxy composes a **public** name and an **internal** name for every route
it is given, because it can.
- **The certificate authority** follows from which names exist. Measured on the control-node: an
identity provider carries a public certificate valid 90 days and an internal one valid 24 hours and
renewed daily. No assignment asked for either.
So *this endpoint must not be public* cannot be written. It is therefore enforced by nothing, while a
public certificate for that very name is obtained automatically — the fault
[how-we-build.md](../00-META/how-we-build.md) names, an unenforced rule being indistinguishable from
a wrong one, with the additional cost that the wrong thing is done eagerly.
And a port that is not routed cannot be spoken about at all beyond the filter. The forge serves git
over ssh; that endpoint has no name, no certificate and no way to be called public except a key only
the filter reads.
**Two per-node settings already exist and are half of this.** One gives a module's declared port a
machine port. One overrides a declared port's source. They key on port numbers, so nothing ties a
port to the route that serves it: a route contribution names a port too, and the two are equal only
by coincidence.
**Where this belongs is already decided.** [ADR 0046](0046-a-module-configuration-is-its-assignments-not-its-manifest.md)
says a module's configuration is its assignments. Whether the forge answers git-over-ssh from the
public internet is a fact about one installation and one machine, not a property of the software —
and [ADR 0112](0112-a-module-definition-names-no-node-mesh-or-path.md) already refuses an
installation's decisions in a definition.
## Considered Options
1. **Leave reach in the manifest, as `from` today.** Rejected: it is an installation's decision
written into the definition, and it cannot differ between two machines running the same module —
which is exactly the case the forge presents.
2. **Extend the existing source override to the names and the certificate, without naming
endpoints.** Rejected: it keys on a port number. A module's route contribution names a port as
well, and nothing says the two are the same thing, so one statement cannot be made to reach all
three mechanisms. Naming the endpoint is what makes that possible.
3. **Derive reach from whether the node has a public domain recorded.** Rejected: that is a property
of the machine, and two endpoints on one machine differ — a database and a web front end on the
same host.
4. **A fourth reach for "public name, internal authority"** — a name that resolves publicly and must
not appear in a public issuance log, obtained by DNS-01. Deferred, not rejected: it is a real case
and it is a question about which challenge an authority uses, not about how far an endpoint
reaches. Left to the certificate work as an open question.
5. **Make the manifest silent on reach and require every assignment to state it.** Rejected for the
transition: every endpoint reachable today would close until an assignment named it, which is a
flag day across the whole catalogue.
## Decision
**A module declares named endpoints.** An endpoint is one port the module serves, with a name the
module chooses, its protocol, and what it is for. A route contribution **names the endpoint it
routes** rather than repeating a port number. The manifest says what the module serves and what it
would serve it to by default; it does not say what this installation does with it.
**An assignment binds each endpoint and says how far it reaches.** Per node: the machine port the
endpoint is published on, and its **reach** — one of `internal`, `public` or `both`. An assignment
that states nothing keeps the manifest's default, so no machine changes until an assignment says so.
**Reach means all three mechanisms at once, and is the only thing that decides them.**
- `internal` — the filter opens the machine port to the private network; the proxy serves the
internal name and not the public one; the certificate comes from the mesh's own authority.
- `public` — the filter opens it to anywhere; the proxy serves the public name; the certificate
comes from the public authority.
- `both` — both names, each from its own authority, and the filter opens to anywhere.
**An endpoint that is not routed is reached but never named.** An endpoint with no route contribution
yields filter rules and nothing else: no name is composed and no certificate is requested. Git over
ssh is that case, and it is the case the model could not express.
**The authority stops being chosen by which names happen to exist.** The proxy composes the names the
assignments asked for, and asks each name's own authority for it. A name nobody asked for is not
composed, so it is not certified.
**The two existing settings are this, completed.** The per-node port mapping becomes the endpoint's
binding. The per-node source override becomes its reach, widened from the filter alone to the names
and the certificate as well.
## Consequences
- **A manifest gains endpoint names, and a route contribution names an endpoint instead of a port.**
Every routed module's manifest changes. The word ships one release before any manifest uses it, and
reaches the build machine and the control plane first.
- **One derived value is read by three things** — the filter's rules, the proxy's contributions, the
certificate request — so they can no longer disagree, and a disagreement becomes a refusal at the
assignment rather than a surprise on a machine.
- **A name that must not be public becomes writable, and therefore checkable.** It also gives
[issue 129](../04-ISSUES/129-nothing-makes-a-machine-trust-the-meshs-authority/00-report.md) a
declared answer to read: which endpoints are internal is what says whose root must be installed
where.
- **[Issue 139](../04-ISSUES/139-an-internal-route-name-resolves-to-the-consumers-node/00-report.md)
becomes answerable**: the endpoint's assignment names the machine that serves it, which is the fact
the internal name should be composed from.
- **Reach becomes reportable.** The mesh can say, per endpoint, where it is reachable from and which
authority holds its certificate — neither of which `status` can say today.
- **This narrows [ADR 0045](0045-a-machine-firewall-is-the-sum-of-what-it-listens-on.md).** Its
decision stands: the firewall is derived and host-applied, not a provider. What no longer holds is
that a listen's `from` is the whole of public-versus-internal; it is the filter's share of a
statement that also governs names and certificates.
- **What got harder:** every endpoint needs a name, including a module that serves exactly one port
and had no reason to name it. And an installation that wants a module public must now say so on the
assignment rather than inheriting it from the definition, which is more to say and the reason it is
right.
## How it is checked
- **One module, two endpoints, different reach.** A module declaring an internal endpoint and a
public one renders a filter opening one to the private network and one to anywhere, asserted per
chain body so a rule in the wrong chain cannot pass.
- **The names follow the reach.** The same module's routed endpoint composes the internal name only
when internal, the public name only when public, and both when both — and a certificate is
requested from the matching authority for each name composed and for no other. This fails against
the previous behaviour, where both names and both certificates are always composed, which is how
it is written.
- **An unrouted endpoint is filtered and never named.** Asserted for an endpoint with reach and no
route contribution: rules rendered, no contribution, no certificate request.
- **An assignment naming an endpoint the module does not declare is refused where it is said**, as is
a reach that is not one of the three — before it reaches a machine, because a ruleset that does not
load is a machine filtering nothing.
- **An assignment that states nothing renders byte-identically to today**, so every machine already
converged is untouched until its assignment says otherwise.
## References
- [ADR 0045](0045-a-machine-firewall-is-the-sum-of-what-it-listens-on.md) — narrowed here
- [ADR 0046](0046-a-module-configuration-is-its-assignments-not-its-manifest.md) — where reach belongs
- [ADR 0066](0066-public-routing-is-name-agnostic.md) — the public name this composes
- [ADR 0112](0112-a-module-definition-names-no-node-mesh-or-path.md) — why reach is not a definition's
- [issue 140](../04-ISSUES/140-an-endpoints-reach-is-not-declared/00-report.md) — the measurement
- [issue 139](../04-ISSUES/139-an-internal-route-name-resolves-to-the-consumers-node/00-report.md),
[issue 129](../04-ISSUES/129-nothing-makes-a-machine-trust-the-meshs-authority/00-report.md)
@@ -0,0 +1,137 @@
---
topic: what runs on it
status: superseded
date: 2026-09-28
deciders: jochen
reconstructed: false
extends: 02-DECISIONS/0137-a-machine-says-which-networks-it-routes.md
superseded-by: 02-DECISIONS/0140-the-filter-constrains-what-arrives-from-outside.md
---
# 139. A network is forwarded because a module declared it
## Context
[ADR 0137](0137-a-machine-says-which-networks-it-routes.md), decided the same week, gave a machine a
way to say which networks it routes for its guests. It was written because the derived filter's
forward chain allowed two ranges named as constants in the control plane's source — the container
runtime's bridge pool, and part of the pool its compose files are given — with a comment admitting
the gap: *a machine whose runtime is configured with something else needs this to say so, which is a
thing the mesh cannot derive.*
**It can be derived, and from the right place.** Measured on the last machine still to be converged
([issue 141](../04-ISSUES/141-the-forward-chain-does-not-follow-the-modules/00-report.md)): twenty-one
container networks, nine inside the runtime's bridge pool, twelve in the other private range, and six
of those outside the constant's lower bound — so the flip would have cut their guests off exactly as
it did on the workstation that produced 0137.
Naming a range to cover the six is what 0137 provides for, and it is the wrong instrument. Of those
six networks, **four are networks the mesh's own modules declare**, present as network resources in
the node's plan and created by the host because a module asked for them. **Two are the predecessor's
leftovers** — compose networks of services the mesh does not run. Any range wide enough to keep the
four forwards the two as well: a firewall widened by hand to protect networks that should not exist.
The mesh already knows which of the twenty-one are its own, because it made them.
**And the node's configuration is meant to follow the modules assigned to it.** That is the mesh's
founding shape — the machine runs modules, and its files, its filter and its accounts are composed
from what runs there ([ADR 0005](0005-the-node-host.md),
[ADR 0010](0010-delivery.md),
[ADR 0045](0045-a-machine-firewall-is-the-sum-of-what-it-listens-on.md)). The forward chain is the
one derived thing that consults a constant and a list a person types.
## Considered Options
1. **Keep 0137 as it stands** — two constants plus a named list. Rejected: the list is written in
addresses, and addresses are what the runtime allocates, so the only entry safe enough to keep a
machine working is wider than the truth. It cannot distinguish a network the mesh made from one
left behind, which is the distinction that decides whether forwarding it is correct.
2. **Derive it from what the machine reports.** Still rejected, on 0137's own grounds: a test bed
creates its bridge between one declaration and the next, and a filter that follows whatever
appeared on a machine is a firewall that widens itself. **This decision is not that** — see below.
3. **Have the control plane allocate each module network's range from a pool it owns,** so it can
render the address itself. Rejected: more machinery for no gain. The runtime already allocates and
the host already knows, and taking allocation over means the mesh owning an address space it has no
other reason to own.
4. **Have each module declare its network's range.** Rejected by
[ADR 0112](0112-a-module-definition-names-no-node-mesh-or-path.md): a definition names no address,
and the same definition runs on machines whose runtimes have allocated differently.
## Decision
**A network is forwarded because a module declared it.** Per node, the forward chain forwards the
networks of the modules assigned there, and by default nothing else. A module unassigned stops being
forwarded at the next reconcile.
**The host resolves a declared network to its addresses.** A network resource carries a name; the
runtime allocates the subnet when the network is created. So the control plane declares *forward the
networks these modules asked for* and the host — which made them, and already resolves a container by
its name — renders the addresses. [ADR 0005](0005-the-node-host.md) holds: the host applies, it does
not decide.
**Deriving from the declaration is not deriving from the machine.** Both of 0137's objections fall
away. The set is known before the network exists, because a module declared it, so a network created
between two declarations is already in the one that asked for it. And it cannot widen itself: a
network nobody declared is never forwarded, however it appeared on the machine.
**The runtime's own default bridge is forwarded, from what the runtime reports.** Containers that name
no module network attach to it, and it belongs to the runtime rather than to any module — so the host
renders it from what the runtime says, not from a range named in the control plane. The constants go.
**What a machine says is for guests no module declares.** A test bed is not a module and its range is
not a module's; that is the case 0137's mechanism is for, and it keeps it — added to the derived set,
never replacing it, as 0137 decided. Narrowed to that, it is named for it.
**Their guests keep address and name service**, per declared network, unchanged from
[ADR 0137](0137-a-machine-says-which-networks-it-routes.md): the input chain admits that network's own
DHCP and DNS and nothing else.
## Consequences
- **The two constants are removed**, and with them the class of fault that a machine's guests depend
on a range that describes some other machine.
- **This is a behaviour change, not a refactor.** On the machine measured, the derived set and the
constant do not cover the same ground — that is the whole reason for the record. A machine whose
module networks happen to fall inside the old ranges renders the same rules.
- **A range that exists only to keep a leftover alive becomes visible as such**, because it will not
be in the derived set and has to be said out loud to survive.
- **`node networks` narrows** to guests no module declares, and the preview says which of a machine's
networks are the mesh's and which are not, so the difference is readable before a flip rather than
after.
- **A module's declaration gains nothing.** It already declares its network; what changes is that the
filter reads it.
- **What got harder:** the host renders part of the forward chain from what it created, so the
control plane no longer holds the whole rule set as text. The rule the mesh states is the set of
networks; the addresses are the machine's.
## How it is checked
- **Only declared networks are forwarded.** A node with two modules that declare networks renders
forward rules for exactly those two, and none for a third network present on the machine that no
module declared. This fails against the previous behaviour, which forwards by range and cannot tell
them apart, and that is how it is written.
- **Unassigning a module removes its network's rule** at the next reconcile, asserted on the rendered
chain rather than on the intent.
- **Guests of a declared network keep address and name service**, asserted per chain body so a line in
the wrong chain cannot pass — carried from 0137.
- **The runtime's own default bridge comes from the runtime**, asserted by rendering for a runtime
whose default bridge is somewhere other than the range the constant named.
- **A machine that names a range for guests no module declares still gets it**, added to the derived
set and not replacing it.
- **Each family is matched in its own syntax**, carried from 0137: one set holding both is a syntax
error, and a ruleset that does not load is a machine filtering nothing while its unit reports a
fault.
## References
- [ADR 0137](0137-a-machine-says-which-networks-it-routes.md) — narrowed here; its mechanism keeps the
case it is right for
- [ADR 0005](0005-the-node-host.md) — the host applies; the addresses are the machine's
- [ADR 0045](0045-a-machine-firewall-is-the-sum-of-what-it-listens-on.md) — the filter is derived from
what runs there
- [ADR 0112](0112-a-module-definition-names-no-node-mesh-or-path.md) — why a module does not name its
range
- [issue 141](../04-ISSUES/141-the-forward-chain-does-not-follow-the-modules/00-report.md) — the
measurement
- [issue 137](../04-ISSUES/137-converging-a-machine-cut-off-its-own-guests/00-report.md) — the
breakage that produced 0137
@@ -0,0 +1,146 @@
---
topic: what runs on it
status: accepted
date: 2026-09-28
deciders: jochen
reconstructed: false
extends: 02-DECISIONS/0045-a-machine-firewall-is-the-sum-of-what-it-listens-on.md
supersedes:
- 02-DECISIONS/0137-a-machine-says-which-networks-it-routes.md
- 02-DECISIONS/0139-a-network-is-forwarded-because-a-module-declared-it.md
---
# 140. The filter constrains what arrives from outside, and says nothing about a machine's own guests
## Context
The filter the mesh derives blocks traffic passing *through* a machine unless something allows it,
because a container's published port is traffic passing through rather than traffic arriving at the
machine itself ([ADR 0100](0100-a-node-in-use-is-adopted-before-it-is-converged.md),
[issue 047](../04-ISSUES/047-the-firewall-does-not-cover-published-container-ports/00-report.md)).
Having blocked all of it, the filter then had to let the machine's own containers reach outward again.
It does that by listing the address ranges those containers sit on.
As rendered on a converged workstation today:
```
policy drop
ct state established,related accept
ip saddr 172.16.0.0/12 accept
ip saddr 192.168.128.0/17 accept
ip saddr 10.0.0.0/8 accept
ip saddr 192.168.16.0/20 accept
... four more
```
Two of those ranges were constants in the control plane's source. The rest were typed by the operator
after [ADR 0137](0137-a-machine-says-which-networks-it-routes.md), which existed to make the typing
possible, because converging that workstation had cut every one of its containers off from the
internet and nothing reported a fault
([issue 137](../04-ISSUES/137-converging-a-machine-cut-off-its-own-guests/00-report.md)).
**The list is the mistake, not its contents.** Every attempt to make it correct fails the same way.
A constant describes one machine. A typed range goes stale, and cannot tell a network the mesh made
from one a predecessor left behind — measured on the control-node, where six such ranges fall outside
the constants and two of the six belong to services the mesh does not run
([issue 141](../04-ISSUES/141-the-forward-chain-does-not-follow-the-modules/00-report.md)).
[ADR 0139](0139-a-network-is-forwarded-because-a-module-declared-it.md) tried to generate the same
list from the modules and put half the rule set on the machine to do it. Three records, one list, and
the list should not exist.
**Because the mesh has no policy about a container reaching outward.** What the filter is for is
stated in [ADR 0045](0045-a-machine-firewall-is-the-sum-of-what-it-listens-on.md): which port is open,
and to whom. That is about what arrives. A container of this machine's own opening a connection to
something else is not a port being opened to anybody, and enumerating the addresses it might do so
from is bookkeeping about the machine's internal plumbing, which the mesh neither owns nor can know.
**The system being replaced never had this fault, and its rule says why.** The chain still protecting
the control-node applies only to traffic arriving on that machine's outward link, and leaves
everything else alone. The mesh's filter dropped that distinction and replaced it with a list of
addresses.
## Considered Options
1. **Keep the list and generate it better** — from the modules' declared networks, or from what the
machine reports. Rejected: [ADR 0139](0139-a-network-is-forwarded-because-a-module-declared-it.md)
is that, and it puts part of the rule set on the machine, which makes the rule set partly the
machine's and the derivation advisory.
2. **Name the guest links instead of their addresses, and allow only those.** Rejected as more than is
needed: it fails in the safe direction, but it is still a list that has to keep up with the
machine, and the thing it protects against — a container reaching outward — is not a thing the mesh
has a position on.
3. **Do not block traffic passing through at all.** Rejected: that is
[issue 047](../04-ISSUES/047-the-firewall-does-not-cover-published-container-ports/00-report.md),
where a published port was reachable from anywhere because no rule mentioned it.
4. **Constrain what arrives from outside, and nothing else.** Adopted.
## Decision
**The filter constrains traffic arriving from outside the machine, and says nothing about traffic that
did not.** Traffic passing through the machine is allowed unless it arrived on one of the machine's
outward links, in which case it is allowed only where a declared endpoint's reach admits it
([ADR 0138](0138-an-assignment-binds-an-endpoint-and-says-how-far-it-reaches.md)). A container of this
machine's own reaching anywhere is not filtered, because the mesh has no position on it.
**A machine says which of its links face outside.** One node-level fact, reported by the machine the
way it already reports the kind of firewall it found and the tunnel it carries — not a setting, not a
list of addresses, and not something anybody types. It does not change when a module is added or
removed, which is what separates it from the list it replaces.
**A machine that has reported no outward link is sent no filter.** Rendering a rule around a link
whose name is not known produces a rule set that does not load, which is a machine filtering nothing
while its unit reports success. The refusal happens in the control plane, where a person reads it, and
the machine keeps the filter it already has.
**No addresses of the machine's own networks appear in the filter.** The two constants are removed and
`node networks` is removed with them, along with everything any machine was told to say through it.
Ports continue to follow the modules exactly as before: a module assigned to a machine opens the port
its assignment says it reaches on, and nothing about a network is said anywhere.
## Consequences
- **Three records collapse into one rule.** 0137 and 0139 are superseded. What 0137 was right about —
that converging a machine had silently cut off its own containers, and that nothing previewed it — is
answered by removing the cause rather than by giving the operator a way to compensate for it.
- **Every machine already converged loses its declared ranges and keeps working**, because the traffic
those ranges allowed is now allowed by not having arrived from outside. The workstation's five ranges
and the laptop's one are deleted rather than migrated.
- **A machine's test beds stop being a special case.** A bed's network is created while the machine
runs and was the case no list could cover; it is now covered by not being mentioned.
- **A new fact travels in the report**, and the control plane refuses to compose a filter without it,
so the order of the roll-out matters: the machines report before the control plane depends on it.
- **A machine with more than one outward link says so**, and a machine that acquires one while the mesh
is not looking is treated as internal until its next report. That window is the cost of this shape;
it is bounded by the report interval, and it exists on machines whose outward link changes, which
are the machines with nothing published to the outside.
- **What got harder:** nothing in the declaration, and one more thing a machine must be able to work
out about itself. A machine that cannot say which link faces outside cannot be given a filter.
## How it is checked
- **A machine's own container reaches outward with no network named anywhere.** A bed converges a
machine carrying containers on several networks, none of them mentioned in any setting, and each
reaches out afterwards. This fails against the previous behaviour, where the same flip cut them off,
and that is how it is written.
- **A port declared reachable from outside is reachable; one that is not, is not.** Probed from off the
machine's private network, for a published port and for an undeclared one, before and after the flip.
- **A network created after the filter was composed needs no new filter.** A network is made on the
machine after its last declaration and a container on it reaches out, with nothing re-sent.
- **No address of a machine's own networks appears in a rendered filter**, asserted on the text so a
range cannot creep back in.
- **A machine that reports no outward link is sent no filter, and the refusal names it** — asserted in
the control plane, and that the machine's existing filter is left alone.
- **A machine reporting two outward links has both constrained**, asserted per chain body so a rule
covering one and not the other cannot pass.
## References
- [ADR 0045](0045-a-machine-firewall-is-the-sum-of-what-it-listens-on.md) — what the filter is for
- [ADR 0138](0138-an-assignment-binds-an-endpoint-and-says-how-far-it-reaches.md) — what admits traffic
arriving from outside
- [ADR 0100](0100-a-node-in-use-is-adopted-before-it-is-converged.md) — why traffic passing through is
filtered at all
- [ADR 0137](0137-a-machine-says-which-networks-it-routes.md),
[ADR 0139](0139-a-network-is-forwarded-because-a-module-declared-it.md) — superseded here
- [issue 137](../04-ISSUES/137-converging-a-machine-cut-off-its-own-guests/00-report.md),
[issue 141](../04-ISSUES/141-the-forward-chain-does-not-follow-the-modules/00-report.md)
+4
View File
@@ -217,6 +217,10 @@ python3 00-META/checks/index.py fail if stale
- **0133** — [A module owns its migrations, and the mesh owns when they run](0133-a-module-owns-its-migrations-and-the-mesh-owns-when-they-run.md) *(superseded)* - **0133** — [A module owns its migrations, and the mesh owns when they run](0133-a-module-owns-its-migrations-and-the-mesh-owns-when-they-run.md) *(superseded)*
- **0135** — [A module version prepares its state before it runs](0135-a-module-version-prepares-its-state-before-it-runs.md) - **0135** — [A module version prepares its state before it runs](0135-a-module-version-prepares-its-state-before-it-runs.md)
- **0136** — [A step gates its module, not the machine](0136-a-step-gates-its-module-not-the-machine.md) - **0136** — [A step gates its module, not the machine](0136-a-step-gates-its-module-not-the-machine.md)
- **0137** — [A machine says which networks it routes](0137-a-machine-says-which-networks-it-routes.md) *(superseded)*
- **0138** — [An assignment binds an endpoint and says how far it reaches](0138-an-assignment-binds-an-endpoint-and-says-how-far-it-reaches.md)
- **0139** — [A network is forwarded because a module declared it](0139-a-network-is-forwarded-because-a-module-declared-it.md) *(superseded)*
- **0140** — [The filter constrains what arrives from outside, and says nothing about a machine's own guests](0140-the-filter-constrains-what-arrives-from-outside.md)
### How it is built ### How it is built
+101 -1
View File
@@ -7,8 +7,10 @@ code:
- mesh-controller internal/identity/authority.go - mesh-controller 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)
updated: 2026-09-27 updated: 2026-09-28
decisions: decisions:
- 02-DECISIONS/0138-an-assignment-binds-an-endpoint-and-says-how-far-it-reaches.md
- 02-DECISIONS/0140-the-filter-constrains-what-arrives-from-outside.md
- 02-DECISIONS/0104-a-provision-may-be-answered-by-an-adapter-to-the-predecessor.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/0106-the-bus-is-nats.md
- 02-DECISIONS/0105-the-mesh-adopts-the-predecessors-tunnel-in-place.md - 02-DECISIONS/0105-the-mesh-adopts-the-predecessors-tunnel-in-place.md
@@ -625,6 +627,50 @@ 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 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. declared port is open and the undeclared one closed.
### It filters what arrives from outside, and not what the machine's own guests send
*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 0140](../../02-DECISIONS/0140-the-filter-constrains-what-arrives-from-outside.md), which replaces
[ADR 0137](../../02-DECISIONS/0137-a-machine-says-which-networks-it-routes.md) and
[ADR 0139](../../02-DECISIONS/0139-a-network-is-forwarded-because-a-module-declared-it.md).*
Traffic passing through a machine is filtered, because a container's published port is traffic passing
through rather than traffic arriving at the machine itself. Having blocked it, the filter then had to
let the machine's own containers reach outward again — and it did that by listing the address ranges
they sit on. Two of those ranges were constants in this repository's code, and the rest were typed by an
operator after the flip had already cut a workstation's containers off from everything.
**The list was the mistake, not its contents.** A constant describes one machine. A typed range goes
stale and cannot tell a network the mesh made from one a predecessor left behind — on the control-node,
six ranges fall outside the constants and two of the six belong to services the mesh does not run. The
attempt to generate the list from the modules put half the rule set on the machine and made the
derivation advisory. Three records, one list.
**And the mesh has no position on a container reaching outward.** §4 exists to say which port is open
and to whom, which is about what arrives. A container of this machine's own opening a connection
somewhere is not a port opened to anybody, and the addresses it might do that from are the machine's
internal plumbing, which the mesh neither owns nor can know.
So the filter constrains what arrives from **outside** the machine and says nothing about what did not.
Traffic passing through is allowed unless it came in on one of the machine's outward links, and then
only where a declared endpoint's reach admits it (§6). The machine says which of its links face
outside — one fact it reports, like the kind of firewall it found and the tunnel it carries, not a
setting and not a list of addresses. It does not change when a module is added or removed, which is the
whole difference from what it replaces. A machine that has reported no outward link is sent no filter
at all, and keeps the one it has, because a rule written around a link with no name is a rule set that
does not load — a machine filtering nothing while its unit reports success.
Ports go on following the modules exactly as before: assign a module to a machine and the port its
assignment says it reaches on opens. Nothing about a network is said anywhere, by anybody.
*How it is checked:* a bed converges a machine carrying containers on several networks, none of them
named in any setting, and each reaches outward afterwards — which fails against the previous behaviour,
where the same flip cut them off, and is how it was written; a network made *after* the last declaration
needs no new filter; a declared port is reachable from off the private network and an undeclared one is
not; no address of a machine's own networks appears in a rendered filter, asserted on the text; and a
machine reporting no outward link is refused in the control plane with its existing filter left alone.
## 5 — Certificates ## 5 — Certificates
**Two authorities, kept separate on purpose.** **Two authorities, kept separate on purpose.**
@@ -710,6 +756,60 @@ that verifies against the internal root and nothing else — which cannot succee
first reached the name to certify it* first reached the name to certify it*
([ADR 0066](../../02-DECISIONS/0066-public-routing-is-name-agnostic.md)). ([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 ## What this removes
The list is worth having in one place, because it is most of the argument: The list is worth having in one place, because it is most of the argument:
@@ -0,0 +1,74 @@
---
status: resolved
opened: 2026-09-28
located-in: [mesh-controller internal/catalogue]
fixed-by: mesh-controller — a machine says which networks it routes and the derived filter forwards them, their guests keeping address and name service ([ADR 0137](../../02-DECISIONS/0137-a-machine-says-which-networks-it-routes.md)).
amended-design:
---
# 137 — Converging a machine cut off its own guests, and nothing said so
## What was observed
A workstation was flipped from adopted to converged, so the mesh's derived filter replaced what was
there. The flip reported success, the machine reported that it had applied its declaration, and every
surface of the mesh read green.
A container on one of that machine's networks could no longer reach anything:
```
192.168.64.2/20
OUTBOUND BLOCKED
```
Five of the machine's container networks were affected, and every network its test beds create. The
reason is in the filter's forward chain, which denies by default and then allows two ranges:
```
ip saddr 172.16.0.0/12 accept # the container runtime's bridge networks
ip saddr 192.168.128.0/17 accept # the networks its compose files are given
```
Those two are constants in the controller. The machine's guests were allocated from neither: its
compose networks from other parts of `192.168/16`, and each test bed a fresh `10.x/24`. So the rules
were correct for a machine whose runtime was left at its defaults, and a guess on this one.
Two further things were closed by the same flip, and for the same reason nobody saw them: a guest asks
its host for an address over DHCP and for names over DNS, both of which arrive at the input chain,
where no module had declared them.
## Why it matters beyond this instance
**The preview could not have warned.** It lists what the machine reported as *listening*, and says so
honestly: it ends with a line that traffic the machine routes is "not previewed". What it did not say
is that routing was about to be denied by default, or which ranges would survive. An operator reading
a 350-line preview approves what it shows.
**It is the second time today that a constant stood in for something the mesh cannot know.** The
intrusion-prevention module named a firewall front-end two machines do not have
([issue 136](../136-a-module-may-name-a-program-the-machine-does-not-have/00-report.md)), and the
filter names the address ranges one runtime happens to use. Both were true where they were written and
silently false elsewhere.
**And the code already knew.** The comment above those two lines says a machine configured otherwise
"needs this to say so — which is a thing the mesh cannot derive and a reason this list is named here
rather than computed". The gap was documented at the point where it was introduced, and the way to say
it was never built. A comment naming a missing mechanism is a rule that is not enforced.
## What was done
A machine says which networks it routes; the filter forwards them and admits their guests' address and
name service. Added to the runtime's defaults rather than replacing them, so a machine that names one
range keeps the others. Node-level, because the machine routes them and the module that loads the
filter may be replaced. The converge preview now says what a machine routes, and what it will keep
forwarding if it says nothing.
## What is still true
**The flip is still the moment a machine's unmanaged services close.** That is what converging means
and the preview names each one. This issue is not about the ports that were meant to close; it is about
the ones nothing could name.
**Egress is still not previewed per network.** The preview says which ranges will be forwarded, not
which of the machine's guests sit inside them. Deriving that would need the machine to report its
bridges, and a bed's bridge does not exist until the bed runs.
@@ -0,0 +1,56 @@
---
status: open
opened: 2026-09-28
located-in: [mesh-controller internal/catalogue, mesh-catalog]
fixed-by:
amended-design:
---
# 138 — Two modules claim one seat and are not interchangeable, and nothing says so
## What was observed
Three modules claim the node-scoped uplink seat: one for each network manager a machine here might
run. [ADR 0117](../../02-DECISIONS/0117-a-machines-uplink-is-a-seat.md) gives each of them the same
job — ask the manager the machine already runs to leave the resolver file alone and to leave the mesh's
interface alone — and deliberately keeps the machine's own links out of the mesh's hands.
A seat means one holder and an interchangeable holder. These are interchangeable in what they *ask*
and not in what they *do*:
- None installs, enables, starts or stops the manager. That is on purpose: stopping it takes every
link down, including the mesh's own way in.
- None carries an address, a route or a wireless credential, for the same reason.
- **Nothing checks that the module holding the seat names the manager the machine is actually
running.** Assigning the systemd-networkd holder to a machine running NetworkManager writes a file
for a daemon that is inactive and disabled, the seat reports held, and the two things the seat
exists to arrange are arranged for nobody. NetworkManager goes back to rewriting the resolver file
on every lease, which is the failure the module's own comment describes.
The machine reports which service manager and which units are active, so the fact needed to catch this
is already in the report the mesh holds.
## Why it matters beyond this instance
**A seat is the mesh's promise that a role is filled.** If the holder can be a module for software
that is not running, the seat says a role is filled while nothing fills it — and the surface that
would tell an operator says "held".
**It is the same shape as two faults found the same day.** A module named a firewall front-end the
machine does not have ([issue 136](../136-a-module-may-name-a-program-the-machine-does-not-have/00-report.md)),
and the filter named address ranges one runtime happens to use
([issue 137](../137-converging-a-machine-cut-off-its-own-guests/00-report.md)). Each is a claim about
the machine that nothing on the machine checks.
**And it decides whether the seat is worth having.** Either the holder must match what the machine
runs, which is a condition the mesh can check from the report it already has, or the holders must be
able to switch the manager, which ADR 0117 refuses for a reason that has not changed.
## Open questions
- Should a seat's conditions of holding include a capability the machine reports, so a holder naming
absent or inactive software is refused rather than recorded?
- Is "the uplink" one seat at all, if its holders are three dialects of the same two requests? The
alternative is one module that speaks whichever dialect the machine needs, chosen from the report.
- What should happen on a machine that switches manager afterwards? The seat would then be held by the
wrong module, and the machine is the only place that knows.
@@ -0,0 +1,51 @@
---
status: open
opened: 2026-09-28
located-in: [mesh-controller internal/catalogue]
fixed-by:
amended-design:
---
# 139 — An internal route name resolves to the consumer's node, not the one that serves it
## What was observed
A module that requires a route is given two names: a public one composed under the serving node's
domain, and an internal one composed under the consumer's own machine — `<label>.<node>.internal`.
The two are published differently:
- The **public** name is written into every machine's hosts file at the address of the node whose
proxy answers it. The mesh computes that deliberately, so any container resolving a routed name
reaches the proxy.
- The **internal** name is resolved by the machine's own resolver, which answers every name under
`<node>.internal` with that node's address — the consumer's, because the name was composed from it.
Where the proxy runs beside the consumer these are the same machine, which is every case on this mesh
today, and both names work. Measured on 2026-09-28: the internal name of a service on the control node
answers with a certificate from the mesh's internal authority, and the public name with one from the
public authority.
Where the proxy is on another machine they disagree. The internal name sends the client to a machine
that runs no proxy and has nothing listening on the port, while the public name sends it to the one
that does.
## Why it matters beyond this instance
**It is latent exactly where the mesh is heading.** `route` is provided mesh-wide precisely so a
module can be routed by a proxy on another machine. The first module assigned that way gets an
internal name that does not work, and the public one that does — with no error anywhere, because both
names resolve.
**A per-machine name is what an operator will reach for.** `<service>.<machine>.internal` reads like a
promise that the service on that machine is reachable there, and the wildcard makes every such name
resolve whether or not anything answers.
## Open questions
- Should the internal name be composed under the serving node, like the public one, or should it stay
the consumer's and be published at the serving node's address like the public name is?
- Is a per-node route holder the real answer — a proxy on every machine that serves its own names —
and if so, is `route` still one mesh-wide provision or a node-scoped seat with a mesh-wide fallback?
- What certifies the name in either case? The certificate is obtained by whoever terminates TLS, and
that is the question above in another form.
@@ -0,0 +1,79 @@
---
status: located
opened: 2026-09-28
located-in:
- mesh-controller internal/catalogue/manifest.go
- mesh-controller internal/catalogue/filtering.go
- mesh-controller internal/catalogue/declaration.go
- mesh-controller examples/route-proxy
- mesh-catalog (every routed module manifest)
fixed-by:
amended-design: 03-DESIGN/01-to-be/08-connectivity.md
---
# 140 — An endpoint's reach is not declared, so three mechanisms each decide it separately
## What was observed
Preparing to converge the mesh's control-node — the last machine still running the firewall it
had before the mesh — the question came up for one module: the forge serves git over ssh, and that
port must stay reachable from outside the private network. Where is that said?
The manifest declares the port with a source of `mesh`, so the derived filter would close it to
everything but the private network. Looking for the place an assignment says otherwise, there are
two per-node settings keys: one that gives a module's declared port a machine port, and one that
overrides a declared port's source. The second has exactly one caller — the function that builds
the node's filter rules. Nothing else in the control plane reads it.
A module's routed endpoint is declared somewhere else entirely: a route contribution naming a label
and a port. It says nothing about reach. The proxy composes a **public** name and an **internal**
name for every route it is given, and obtains a certificate for each from a different authority.
Measured on that machine the same day: an identity provider's public name signed by the public
authority for 90 days, its internal name signed by the mesh's own intermediate for 24 hours and
renewed daily. Both names exist, and both certificates, because the proxy makes every name it can.
No assignment asked for either.
So the forge's ssh endpoint has a firewall source and nothing else — no name, no certificate, and no
way to say it should be public other than a key the filter alone reads. And the forge's web endpoint
has two names and two certificates that nobody requested.
## Why it matters beyond this instance
**Reach is stated twice, in two vocabularies, in two places that cannot disagree out loud.** A port
may be exposed to anywhere while the module contributes no public route; a public route may be served
for a module whose own listen is private. Nothing reconciles the pair or refuses it. Each mechanism
is separately defensible and the combination is unstated.
**The vocabulary belongs to the filter, not to reachability.** *Public, internal, or both* cannot be
expressed. A source of `anywhere` is one rule on one chain; it says nothing about which names should
exist or which authority should sign them. So "this endpoint must not be public" has no way to be
written, and is therefore enforced by nothing — while a public certificate for that very name is
obtained automatically.
**An endpoint is not a thing in the model.** A module has ports, and separately it has routes.
Nothing binds a port to a name to a certificate, which is why three mechanisms each decide reach on
their own and none of them is wrong. This is
[ADR 0045](../../02-DECISIONS/0045-a-machine-firewall-is-the-sum-of-what-it-listens-on.md)'s fault
one level up: that record closed "a declaration that reads as a restriction and restricts nothing"
for the packet filter. Here the declaration is absent altogether and the mechanisms guess.
**It blocks the certificate work.** The open question recorded for certificates — a name that must
not be public needs either DNS-01 or the internal authority only — cannot be answered while no
assignment states whether a name should be public. Neither can expiry reporting, revocation, or what
happens to a name when a machine leaves: all of them need to know which names were *meant*.
## Open questions
- Should an assignment name each of a module's endpoints, bind it to a node-level port, and state
whether it is reachable publicly, internally or both — with the filter, the proxy's names and the
certificate authority all derived from that one statement?
- What is an endpoint that is neither routed nor certified? Git over ssh is public reach with no name
and no certificate; the model has to hold that without inventing one.
- Are the two existing settings keys the same statement, half-built? If so, is this a new declaration
or the completion of theirs?
- Does an internal-only endpoint get a certificate at all, and from which authority — and does that
settle [issue 129](../129-nothing-makes-a-machine-trust-the-meshs-authority/00-report.md), where nothing
installs the mesh's own root?
- Does declaring reach per assignment also settle
[issue 139](../139-an-internal-route-name-resolves-to-the-consumers-node/00-report.md), where an
internal name resolves to the consumer's machine instead of the one serving the endpoint?
@@ -0,0 +1,88 @@
---
status: located
opened: 2026-09-28
located-in:
- mesh-controller internal/catalogue/filtering.go
- mesh-host internal/apply
fixed-by:
amended-design: 03-DESIGN/01-to-be/08-connectivity.md
---
# 141 — The forward chain does not follow the modules, though the modules declare their networks
## What was observed
[ADR 0137](../../02-DECISIONS/0137-a-machine-says-which-networks-it-routes.md), decided the same
week, gave a machine a way to say which networks it routes for its guests, because the derived
filter's forward chain had until then allowed two ranges named as constants in the control plane's
own source — the container runtime's default bridge pool, and half of the pool its compose files
are given.
Checking the last machine still to be converged, the same fault was found to be live there, and the
declaration needed to work around it turned out to be wrong in kind.
That machine hosts twenty-one container networks. Nine fall inside the runtime's bridge pool and
are forwarded. Twelve sit in the other private range, and **six of those fall below the lower bound
of the constant**, so the flip would have cut their guests off exactly as it did on the workstation
that produced 0137.
Naming a range to cover the six was the obvious move, and is what 0137 provides for. But of those
six networks, **four are networks the mesh's own modules declare** — they appear as network
resources in the node's plan, created by the host because a module asked for them — and **two are
leftovers of the predecessor**, compose networks of services the mesh does not run. A range wide
enough to keep the four would have forwarded the two as well: a firewall widened by hand to protect
networks that should not exist.
The mesh already knows which of the twenty-one are its own. It made them.
## Why it matters beyond this instance
**The node's configuration is supposed to follow the modules assigned to it.** That is the mesh's
founding shape — the machine runs modules, and its files, its filter and its accounts are composed
from what runs there. The forward chain is the one derived thing that does not: it consults two
constants and, since 0137, a list a person types. A module added tomorrow brings a network the filter
will not forward; a module deprecated leaves a range in the list that outlives it.
**A typed range cannot distinguish the mesh's networks from what was left behind.** It is stated in
addresses, and addresses are what the runtime allocates, so the only honest declaration is one wide
enough to include whatever else the runtime has handed out. The derivation is narrower than anything
a person can safely write, because it names networks rather than ranges.
**0137 rejected deriving this, and was right about what it rejected.** It considered deriving the
list from *what the machine reports* and refused, on two grounds: a test bed creates its bridge
between one declaration and the next, and a filter that follows whatever appeared on the machine is a
firewall that widens itself. Deriving from the **declaration** is neither. The set is known before
the network exists, because a module declared it; and it cannot widen itself, because only a network
some module asked for is ever forwarded. What remains genuinely for a machine to say is guests no
module declares — a test bed's pool — which is a much smaller residue than the list as it stands.
**The gap is invisible in the one place that should show it.** The converge preview lists what
*listens*, and routing is not a listener. It says in one line what the machine routes, and a reader
has to know the runtime's allocations to tell whether that line is sufficient. On the machine
measured here it read as though nothing needed saying.
## What was decided
*2026-09-28, later the same day.* The answer is not a better list. The question in the first open
item below — should the chain be derived from the networks the modules declare — was answered *no*,
after a converged machine's rendered rules were read: the chain blocks everything passing through the
machine and then allows its own guests back by listing their addresses. Every route to a correct list
fails, because the mesh has no position on a container reaching outward in the first place. The filter
now constrains what arrives from **outside** the machine and says nothing about what did not, and a
machine says which of its links face outside — one reported fact instead of a list. See
[ADR 0140](../../02-DECISIONS/0140-the-filter-constrains-what-arrives-from-outside.md), which
supersedes both 0137 and the first attempt at answering this.
## Open questions
- Should the forward chain be derived from the network resources the node's modules declare, with the
host resolving each declared network to its address the way it already resolves a container by name?
The controller cannot render the address itself: a module's network resource carries a name, and the
runtime allocates the subnet at creation.
- What remains of `node networks` once that exists — only guests no module declares, such as a test
bed's pool? And should it then be named for that, rather than for all routing?
- The runtime's own default bridge, which containers attach to when no module network is named, is
not a module's network. Is it derived from the machine, declared by the module that owns the
runtime, or left as the one constant?
- Should the preview say which of a machine's networks are the mesh's and which are not, so a range
that exists to protect a leftover is visible as such?
@@ -0,0 +1,76 @@
---
status: located
opened: 2026-09-29
located-in:
- mesh-host internal/upgrade
- mesh-host cmd/mesh-host
- mesh-controller (no build source for the host; no resource delivers it)
fixed-by:
amended-design:
---
# 142 — The host is the one thing the mesh does not deliver
## What was observed
A change to the host was merged and could not reach any machine without a person copying a file.
Checked on the mesh of four machines, 2026-09-29:
- **The host is not a build target.** Asked what had been built for it, the control plane answered
`nothing has been built for mesh-host`. A merge on the forge builds every changed module and the
control plane itself, because the control plane is a module. The host is not one, and nothing
builds it.
- **No declaration delivers it.** No resource kind names an executable to place on a machine, and
nothing on a machine fetches one.
- **The half that recovers from a bad host exists and is unused.** `internal/upgrade` can report that
the executable this process started from has been replaced on disk, and records which version last
completed a reconcile so a shell script can roll back a host that will not start. The launcher reads
that record and rolls back. But `Replaced()` is called by nothing except its own tests — the
recovery is wired and the delivery was never built.
- **Every machine runs a byte-identical binary, stamped by hand.** All four carry the same size and
the same timestamp, from the last time somebody built it on a workstation and copied it out. No
package owns the file.
## Why it matters beyond this instance
**The component that implements updating is the one thing not updated.** The mesh's stated shape is
that a push produces the right builds and they reach the machines running them with nobody asking. It
is true of every module and of the control plane. It is false for the host, which is what applies all
of them.
**It is a bootstrap problem being answered by a person.** The host cannot be an ordinary module
because the host is what applies modules; a module that replaces the thing applying it has to survive
its own replacement. That is a real difficulty, and the work already done — noticing that the
executable changed, recording a known-good version, a launcher that rolls back — is the hard half of
solving it. What is missing is the easy half, and its absence makes the hard half dead code.
**A hand-copied binary has no record anywhere.** Nothing says which version a machine runs, so
nothing can say a machine is behind, and the mesh's own account of itself — every machine current with
its source — cannot include the host. Four machines agreeing today is luck, not a property.
**And it silently gates any change that starts in the host.** A change that needs the host to report
something new cannot be rolled out by merging it: the control plane must wait for a person, and until
then it either refuses what depends on the new report or renders something wrong. That cost is paid by
every future change of this shape, and it was paid today.
## Open questions
- How is the host delivered without being applied by itself? A candidate shape: the host is built like
anything else, published as an artifact, and the *running* host fetches and stages the next one, then
stands aside — which is what `Replaced()` was written for and what the launcher's rollback already
covers.
- **Should this ride the bus, rather than becoming a mechanism of its own?** Everything else that
reaches a machine already does: a declaration is sent over it, a report comes back over it, and a
build announces what it produced on it, which is how a module's new version reaches the machines
running it. A host build announcing itself the same way, consumed by the host already running,
would make this the existing mechanism pointed at one more artifact rather than a second way of
delivering things. It would also give the machine somewhere to say which host it is running, on the
report it already sends.
- What records which version of the host a machine runs, so "behind" is answerable? Nothing does now.
- Does the host's version belong in its report, beside the other facts a machine states about itself?
- Who decides when a machine takes a new host — the mesh, on a build, or an operator per machine as
with converging? The rollback path means a bad host costs a reconcile rather than a machine, which
argues for the former.
- Does the same gap apply to the launcher and the units beside the binary, which are also files no
declaration names?