--- 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. ## Progressive insight — 2026-09-29, from building it **Reach does not mean the same thing to the filter for an endpoint the proxy serves.** The decision above says `internal` means "the filter opens the machine port to the private network" and `public` means "the filter opens it to anywhere". For a routed endpoint the second half is wrong, and [ADR 0045](0045-a-machine-firewall-is-the-sum-of-what-it-listens-on.md) already said so before this record was written: *a public service is exposed through the proxy, not by opening its own port* — it listens `from: mesh`, only the proxy reaches it, and it is exposed by name. Found by trying to express one real module, not by review. Its routed name must be public, because browsers post to it; its machine-side port must not be, because that port serves the dashboard in cleartext. Under one value driving both, saying "public" would have reopened a port an operator had just closed. Measured the same evening: that module's routed name answered from the internet over TLS while its machine-side port was refused from the same place. The port is not the path. So the reach of a **routed** endpoint asks for names, and its port keeps what the manifest said. The reach of an **unrouted** endpoint — git over ssh, a mail port, the bus — governs the port, because there is no name and the port is the only way in. That is the same split this record already draws in *an endpoint that is not routed is reached but never named*; what it got wrong was carrying the filter across it. This corrects a fact, not the decision: one statement per endpoint, three things derived from it and none of them deciding on its own, all stand. The table in the decision should be read with the filter column applying to an unrouted endpoint. ## 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)