From 7b4916e9ecc2e7a0113788e01fe09836d19dc608 Mon Sep 17 00:00:00 2001 From: jochen Date: Sat, 26 Sep 2026 20:34:32 +0200 Subject: [PATCH] Modules declare their own seats; the mesh reserves mesh-* MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The architecture 0117 opened needs a module to offer a service as a role on the bus — one holder, addressed by what it does. A closed table in the controller cannot express that: a capability a module contributes would require changing the mesh itself. But 0110 closed the set for a good reason — nothing could say what seats a mesh had, and the hand count came out at eleven of thirteen. That argues for enumerable, not hardcoded, and 0110 weighed free-form against a fixed table without considering a third option: closed at any moment and derived from the catalogue. A derived list cannot drift, which is how the count broke. So: the mesh's seats stay the mesh's, reserved by the mesh- prefix so the prefix is the rule and there is no list to maintain; ten seats are renamed to restore 0079's convention; everything 0110 decided about what a seat IS survives untouched. Design 29 carries the declaration model: three namespaces, subjects derived from local names so a manifest survives the wire changing, queues never declared, five relationships (the job and state shapes 0041 had no room for), and the build-publish-deploy lifecycle with hard, soft and build-time dependencies distinguished. 0041 gets a progressive insight: "no per-consumer setup, only a subscription" was a fact about a topic exchange, and a JetStream durable consumer is a real object someone creates. WBS 1.3/1.4 were wrong and say so: streams come at registration and consumers at assignment, so only the foundation set belongs at genesis. --- 00-META/checks/records.py | 10 +- 00-META/glossary.md | 2 +- .../0041-events-are-a-relationship.md | 16 ++ ...s-a-module-assignment-from-a-closed-set.md | 3 +- .../0118-a-module-declares-its-own-seats.md | 130 +++++++++++ 02-DECISIONS/README.md | 3 +- 03-DESIGN/01-to-be/23-choosing-a-provider.md | 4 +- 03-DESIGN/01-to-be/26-the-seats.md | 79 ++++--- .../27-a-module-requires-the-mesh-resolves.md | 6 +- 03-DESIGN/01-to-be/28-building-the-bus.md | 32 ++- .../01-to-be/29-what-a-module-declares.md | 215 ++++++++++++++++++ 03-DESIGN/01-to-be/README.md | 6 +- 12 files changed, 461 insertions(+), 45 deletions(-) create mode 100644 02-DECISIONS/0118-a-module-declares-its-own-seats.md create mode 100644 03-DESIGN/01-to-be/29-what-a-module-declares.md diff --git a/00-META/checks/records.py b/00-META/checks/records.py index ed6c186..dd1f5c1 100644 --- a/00-META/checks/records.py +++ b/00-META/checks/records.py @@ -331,7 +331,15 @@ def check_progressive_insights(failures, records): if any(s <= m.start() and m.end() <= e for s, e in covered): continue line = text.rfind("\n", 0, m.start()) + 1 - if text[line:m.start()].lstrip().startswith("#"): + end = text.find("\n", m.end()) + whole = text[line:end if end != -1 else len(text)] + if whole.lstrip().startswith("#"): + continue + # A line that also carries a link is discussing the rule, not marking a correction: + # a marker never needs to cite anything, and a record that reasons about the policy + # must be able to name it. Bare prose with no citation is the informal marking this + # is here to catch. + if "](" in whole: continue if loose.search(text, line, text.find("\n", m.end()) + 1 or len(text)): continue diff --git a/00-META/glossary.md b/00-META/glossary.md index 4ffe66e..d2a2958 100644 --- a/00-META/glossary.md +++ b/00-META/glossary.md @@ -47,7 +47,7 @@ another — and a mesh you cannot name precisely is a mesh two people describe d - **seat** — a named role at a scope (node / site / mesh), held by a module assignment, from a **closed set** the mesh defines: a claim naming a seat outside the set is refused. A seat may **deliver a provision**, and its holder is then the mesh's answer for it when several modules - provide it ([ADR 0110](../02-DECISIONS/0110-a-seat-is-a-module-assignment-from-a-closed-set.md)). + provide it ([ADR 0118](../02-DECISIONS/0118-a-module-declares-its-own-seats.md) (superseding [ADR 0110](../02-DECISIONS/0110-a-seat-is-a-module-assignment-from-a-closed-set.md))). The set, with who holds each seat, is the overview of what a mesh has ([26 — The seats](../03-DESIGN/01-to-be/26-the-seats.md)). A seat has a **capacity**: a capacity-1 seat is exclusive (one holder); a higher-capacity seat is a **bench** (several holders diff --git a/02-DECISIONS/0041-events-are-a-relationship.md b/02-DECISIONS/0041-events-are-a-relationship.md index a381307..cd38e97 100644 --- a/02-DECISIONS/0041-events-are-a-relationship.md +++ b/02-DECISIONS/0041-events-are-a-relationship.md @@ -76,6 +76,22 @@ three relationships, one broker, one runtime, all declared on the manifest. - The runtime must dispatch a module's event handlers as well as its tools; that generalisation is small (both arrive by importing the module's entrypoint) but it is real work. +## Progressive insight + +> **Progressive insight — 2026-09-26.** *"No provisioner and no per-consumer setup" was a fact +> about the transport, and the transport changed.* This record's table says an event's machinery is +> "nothing but the broker's topic routing", and the text that an event needs "no per-consumer setup +> — only a subscription". That was true of a topic exchange, where a binding cost nothing and the +> broker fanned out. On NATS +> ([ADR 0106](0106-the-bus-is-nats.md)) a subscription is a **durable consumer**: a real object +> with a name, an ack policy, a delivery limit and its own ack subject, created when a module is +> assigned and removed when it is not. Per-consumer setup exists, and the controller does it. +> +> The decision is untouched — events are declared on both sides, 1:many, credential-free, and +> still provisioning's lighter sibling; the lightness is now relative rather than absolute. +> [ADR 0118](0118-a-module-declares-its-own-seats.md) adds the relationship this record's two +> columns had no room for: work addressed to a role, where exactly one holder must act. + ## References - [ADR 0002](0002-nodes-communicate-over-a-broker.md) — the broker events ride. diff --git a/02-DECISIONS/0110-a-seat-is-a-module-assignment-from-a-closed-set.md b/02-DECISIONS/0110-a-seat-is-a-module-assignment-from-a-closed-set.md index ec85827..432be02 100644 --- a/02-DECISIONS/0110-a-seat-is-a-module-assignment-from-a-closed-set.md +++ b/02-DECISIONS/0110-a-seat-is-a-module-assignment-from-a-closed-set.md @@ -1,6 +1,7 @@ --- topic: what runs on it -status: accepted +status: superseded +superseded-by: 02-DECISIONS/0118-a-module-declares-its-own-seats.md date: 2026-09-25 deciders: jochen reconstructed: false diff --git a/02-DECISIONS/0118-a-module-declares-its-own-seats.md b/02-DECISIONS/0118-a-module-declares-its-own-seats.md new file mode 100644 index 0000000..6b9fe73 --- /dev/null +++ b/02-DECISIONS/0118-a-module-declares-its-own-seats.md @@ -0,0 +1,130 @@ +--- +topic: the tiers +status: accepted +date: 2026-09-26 +deciders: jochen +reconstructed: false +supersedes: 02-DECISIONS/0110-a-seat-is-a-module-assignment-from-a-closed-set.md +--- + +# 118. A module declares its own seats; the mesh reserves its own + +## Context + +[ADR 0110](0110-a-seat-is-a-module-assignment-from-a-closed-set.md) closed the set of seats. Its +evidence was strong and still is: nothing could answer *which seats does this mesh have, and who +holds each*. Answering it meant reading every manifest in two repositories and then the +controller's own code, and when that enumeration was done by hand while writing the record, **it +reported eleven claims where there were thirteen.** The fix was a table in the controller, and +adding a seat became a decision. + +What that table cannot express is the architecture [ADR 0117](0117-the-bus-is-the-only-broker.md) +opened. With one bus and no private brokers, a module offering a service to other modules offers +it as **a role on the bus**: a set of subjects, exactly one holder, addressed by what it does +rather than by which module or node provides it. A telegram sender, a licensing master, anything +a mesh might want one of. Under a closed table, adding any of those means editing the controller +— so a capability contributed by a module would require a change to the mesh itself, which is the +coupling the module system exists to prevent. + +**The two requirements look opposed and are not.** 0110 needs the set *enumerable*. The +architecture needs it *extensible*. Those conflict only if enumerable means *written down in one +place by hand* — which is exactly the property that let the count drift in the first place. + +## Considered Options + +1. **Keep the closed table, add each new seat by decision.** Rejected. Every capability a module + contributes would need a change to the controller and a record before it could be offered, and + the mesh would carry the names of services it does not itself implement. +2. **Free-form seats, as before 0110.** Rejected for 0110's own reason, unchanged: nothing can + say what a mesh has, and a name invented at a claim site is a name nobody can explain later. +3. **A set that is closed at any moment and derived rather than maintained**, with the mesh's own + seats reserved by name. Adopted. 0110 weighed options 1 and 2 and never considered this one. + +## Decision + +**A seat may be declared by a module, and the set of seats a mesh has is derived: the mesh's own, +plus those declared by every module it has registered.** The set is still closed — a seat named +nowhere is refused — but it is computed from the catalogue rather than written in the controller. + +Everything 0110 decided about what a seat *is* stands untouched: one holder at its scope; a +definition says which seats a module *can* hold and an assignment says which it *does*; holding +one may deliver a provision; a seat makes a role singular, never a module. + +**Enumeration is a query, not an inventory.** The catalogue knows every registered manifest, so +"which seats does this mesh have, and who holds each" is answered by asking it. This is a +stronger answer than the table gave, not a weaker one: a derived list cannot drift from reality, +and drift is how the hand-made count came out at eleven of thirteen. + +**The mesh's own seats are reserved by prefix.** Every seat the mesh itself defines is named +`mesh-*`, and a module declaring any `mesh-*` name is refused at registration. The prefix *is* +the reservation rule — no list of reserved names to maintain, and no way for the mesh's own +namespace to be colonised by a manifest. This requires renaming the seats that drifted from +[ADR 0079](0079-the-foundation-seats-are-named-after-their-servers.md)'s convention: `the-catalogue` +becomes `mesh-catalog`, `git` becomes `mesh-git`, and the node-scoped `the-build-machine`, +`the-dns-port`, `the-intrusion-prevention`, `the-packet-filter`, `the-private-network`, +`the-resolver-configuration`, `the-showcase` take the same prefix. + +The mesh's seats stay the mesh's for a reason that does not apply to a module's: **the mesh's own +code looks them up by name.** The resolver *is* the thing that finds the store. `mesh-store` is +not a convention the controller follows, it is an identifier the controller dereferences. + +**A declared seat carries a protocol.** A module declaring a seat says what may be sent to it, +what it emits, and what it serves. The holder must satisfy it; a module may not claim a seat whose +protocol it does not implement. Callers declare that they use the *seat*, never the module, so +replacing the implementation changes nothing for any caller. + +**A seat is for a role; an event stays addressed to its emitter.** The two are not +interchangeable and the choice is not stylistic. An event is *this happened to me* — the emitter's +identity is the meaning, which is why the envelope carries source, node and time +([ADR 0042](0042-the-shape-of-an-event-on-the-wire.md)); routing it through a role would erase the +provenance an audit needs. A seat is *this capability, whoever provides it* — where not knowing +the holder is the point. Publish an event when the fact is about you; declare a seat when you are +offering something another module could offer instead. + +**Two modules declaring the same seat name is refused at registration**, second one loses. +Registration is the last moment the mesh can still say no, and a seat name meaning two different +protocols is the failure nobody could diagnose afterwards. + +## Consequences + +- **The controller's seat table stops being the set** and becomes the mesh's own reserved entries. + Resolution reads the catalogue for the rest. +- **Ten seats are renamed.** A rename is a migration, not an edit: existing assignments hold the + old names, so the change carries a mapping and is applied once, and the lab beds that name seats + are updated with it. +- **A `uses` naming an undeclared seat is refused at registration**, which is where 0110's + guarantee lands under this model — the same refusal, at the same moment, from a derived set. +- **Adding a capability stops requiring a decision record.** That is a real loss of governance and + the intended trade: the argument for a seat's existence moves into the module that declares it, + where it is reviewed as part of the manifest. The mesh's own seats keep the old bar. +- **[ADR 0041](0041-events-are-a-relationship.md)'s machinery claim is already stale** for a + different reason, and is corrected in place there under the rule in + [`README.md`](README.md) — a progressive insight: on JetStream a subscription is a durable + consumer, a real object someone must create. +- **What got harder:** a seat's protocol is now a compatibility surface between modules that do + not know each other. Changing one breaks callers already bound to it, and nothing here says how + that is versioned. It is the first thing to answer in the design, and the thing most likely to + hurt later rather than now. + +## How it is checked + +- **The overview answers, and is right.** A command lists every seat, its scope, its protocol and + its holder, derived from the catalogue — and a test asserts the count against a fixture mesh, + because an enumeration nobody checks is how thirteen became eleven. +- **`mesh-*` is refused to a module.** A registration test: a manifest declaring `mesh-anything` + is refused, naming the prefix as the reason. +- **An undeclared seat is refused.** A registration test on `uses`, and a resolution test that + nothing reaches runtime unresolved. +- **A second declarer loses.** A registration test: two manifests, same seat name, the second + refused and the first untouched. +- **A holder must satisfy the protocol.** A claim whose module does not serve what the seat + declares is refused at assignment, not discovered when a caller times out. + +## References + +- [ADR 0110](0110-a-seat-is-a-module-assignment-from-a-closed-set.md) — superseded here; its + requirement is kept and only its mechanism replaced. +- [ADR 0117](0117-the-bus-is-the-only-broker.md) — one bus, which is what makes a role addressable. +- [ADR 0079](0079-the-foundation-seats-are-named-after-their-servers.md) — the naming convention + the reserved prefix restores. +- [ADR 0041](0041-events-are-a-relationship.md) — the event half of the boundary drawn here. diff --git a/02-DECISIONS/README.md b/02-DECISIONS/README.md index 8e1658b..fcbe797 100644 --- a/02-DECISIONS/README.md +++ b/02-DECISIONS/README.md @@ -164,6 +164,7 @@ python3 00-META/checks/index.py fail if stale - **0098** — [A fact a provider makes at first start is fetched from it, not carried in its manifest](0098-a-fact-a-provider-makes-at-first-start-is-fetched-from-it.md) - **0108** — [A route carries the policy applied to a request, and names a secret rather than holding one](0108-a-route-carries-the-policy-applied-to-a-request.md) - **0109** — [A package registry seat is one per ecosystem, not one for all of them](0109-a-package-registry-seat-is-one-per-ecosystem.md) +- **0118** — [A module declares its own seats; the mesh reserves its own](0118-a-module-declares-its-own-seats.md) ### What runs on them, and how it gets there @@ -195,7 +196,7 @@ python3 00-META/checks/index.py fail if stale - **0087** — [A seeded file is created once, and what grows in it is not the mesh's](0087-a-seeded-file-is-created-once.md) - **0091** — [A mount is declared, and there are three things it can be](0091-a-mount-is-declared-three-ways.md) - **0099** — [A step that runs once names what it reads, and runs again when it changed](0099-a-step-that-runs-once-names-what-it-reads.md) -- **0110** — [A seat is held by one assignment, from a closed set, and it may deliver a provision](0110-a-seat-is-a-module-assignment-from-a-closed-set.md) +- **0110** — [A seat is held by one assignment, from a closed set, and it may deliver a provision](0110-a-seat-is-a-module-assignment-from-a-closed-set.md) *(superseded)* - **0112** — [A module definition names no node, no mesh and no path: everything it needs is a requirement the mesh resolves](0112-a-module-definition-names-no-node-mesh-or-path.md) *(proposed)* - **0113** — [The vault makes every shared secret, a provider makes resources and data, and the mesh carries both](0113-the-vault-makes-every-secret.md) *(proposed)* - **0114** — [A credential two parties hold rotates over two credentials; one a single party holds rotates in place, staged; and retiring a credential never removes what it reached](0114-a-shared-credential-rotates-over-two-credentials.md) *(proposed)* diff --git a/03-DESIGN/01-to-be/23-choosing-a-provider.md b/03-DESIGN/01-to-be/23-choosing-a-provider.md index e3f36c0..b2dc979 100644 --- a/03-DESIGN/01-to-be/23-choosing-a-provider.md +++ b/03-DESIGN/01-to-be/23-choosing-a-provider.md @@ -6,7 +6,7 @@ updated: 2026-09-25 decisions: - 02-DECISIONS/0084-which-provider-serves-a-consumer.md - 02-DECISIONS/0027-a-provision-names-what-the-consumer-is-coupled-to.md - - 02-DECISIONS/0110-a-seat-is-a-module-assignment-from-a-closed-set.md + - 02-DECISIONS/0118-a-module-declares-its-own-seats.md --- # 23 — Choosing a provider @@ -54,7 +54,7 @@ to that provider and not to whichever one is nearest. **A seat names the mesh's one provider of a kind.** Where a seat delivers the provision, its holder answers for it when several providers exist and the consumer named none. That is not picking: the choice was made once, mesh-wide, by assigning the holder, rather than once per consumer by naming it -([ADR 0110](../../02-DECISIONS/0110-a-seat-is-a-module-assignment-from-a-closed-set.md), +([ADR 0118](../../02-DECISIONS/0118-a-module-declares-its-own-seats.md) (superseding [ADR 0110](../../02-DECISIONS/0110-a-seat-is-a-module-assignment-from-a-closed-set.md)), [26 — The seats](26-the-seats.md)). A named provider still wins over the seat, because a consumer coupled to particular contents has said so. diff --git a/03-DESIGN/01-to-be/26-the-seats.md b/03-DESIGN/01-to-be/26-the-seats.md index 65dc1b9..c64e07b 100644 --- a/03-DESIGN/01-to-be/26-the-seats.md +++ b/03-DESIGN/01-to-be/26-the-seats.md @@ -10,7 +10,7 @@ code: - mesh-catalog modules/gitea/module.json updated: 2026-09-26 decisions: - - 02-DECISIONS/0110-a-seat-is-a-module-assignment-from-a-closed-set.md + - 02-DECISIONS/0118-a-module-declares-its-own-seats.md - 02-DECISIONS/0111-a-build-source-is-on-the-git-seat-or-external.md - 02-DECISIONS/0109-a-package-registry-seat-is-one-per-ecosystem.md --- @@ -47,27 +47,42 @@ nobody argued for is an entry nobody can explain. ## The set -| seat | scope | delivers | typically held by | -|---|---|---|---| -| `mesh-controller` | mesh | — | the controller | -| `mesh-store` | mesh | — | the store the mesh's own records live in | -| `mesh-broker` | mesh | — | the broker carrying the mesh's own bus | -| `mesh-vault` | mesh | `secret`, reserved | the vault | -| `the-artifact-store` | mesh | `artifact-store` | the artifact registry | -| `the-catalogue` | mesh | — | the catalogue | -| `npm-package-registry` | mesh | `npm-package-registry` | the forge | -| `git` | mesh | `git` | the forge | -| `the-build-machine` | node | — | a builder | -| `the-dns-port` | node | — | the local resolver | -| `the-intrusion-prevention` | node | — | an intrusion-prevention service | -| `the-packet-filter` | node | — | the packet filter | -| `the-private-network` | node | — | the private network the mesh runs over | -| `the-resolver-configuration` | node | — | whichever of the alternative resolver configurations is chosen | -| `the-showcase` | node | — | the showcase module | +**The set is derived, and only the mesh's half is written here.** Revision, 2026-09-26 +([ADR 0118](../../02-DECISIONS/0118-a-module-declares-its-own-seats.md), superseding +[ADR 0110](../../02-DECISIONS/0110-a-seat-is-a-module-assignment-from-a-closed-set.md)): a module +declares its own seats with their protocols, so the seats a mesh has are the mesh's own **plus +every registered module's**. The set is still closed — a seat named nowhere is refused — but it is +computed from the catalogue rather than maintained by hand, which is the property 0110 actually +needed and the table could not keep. -The controller holds this set in code, and a test asserts both its size and that every entry names -the record that made it a seat. **This table and [ADR 0110](../../02-DECISIONS/0110-a-seat-is-a-module-assignment-from-a-closed-set.md) -govern, and code that disagrees is what is wrong.** The implementation in progress predates several +**Every seat below is named `mesh-*`, and the prefix is the reservation rule**: a module declaring +any `mesh-*` name is refused at registration, so there is no reserved-names list to drift. Ten of +these are renamed to restore [ADR 0079](../../02-DECISIONS/0079-the-foundation-seats-are-named-after-their-servers.md)'s +convention, which later seats departed from. + +| seat | was | scope | delivers | typically held by | +|---|---|---|---| +| `mesh-controller` | — | mesh | — | the controller | +| `mesh-store` | — | mesh | — | the store the mesh's own records live in | +| `mesh-broker` | — | mesh | — | the broker carrying the mesh's own bus | +| `mesh-vault` | — | mesh | `secret`, reserved | the vault | +| `mesh-artifact-store` | `the-artifact-store` | mesh | `artifact-store` | the artifact registry | +| `mesh-catalog` | `the-catalogue` | mesh | — | the catalogue | +| `mesh-npm-package-registry` | `npm-package-registry` | mesh | `npm-package-registry` | the forge | +| `mesh-git` | `git` | mesh | `git` | the forge | +| `mesh-build-machine` | `the-build-machine` | node | — | a builder | +| `mesh-dns-port` | `the-dns-port` | node | — | the local resolver | +| `mesh-intrusion-prevention` | `the-intrusion-prevention` | node | — | an intrusion-prevention service | +| `mesh-packet-filter` | `the-packet-filter` | node | — | the packet filter | +| `mesh-private-network` | `the-private-network` | node | — | the private network the mesh runs over | +| `mesh-resolver-configuration` | `the-resolver-configuration` | node | — | whichever of the alternative resolver configurations is chosen | +| `mesh-showcase` | `the-showcase` | node | — | the showcase module | + +The controller holds **the mesh's own** entries in code, and a test asserts their size and that +every one names the record that made it a seat. A module's seats are not here and never will be — +they are read from the catalogue. **This table and +[ADR 0118](../../02-DECISIONS/0118-a-module-declares-its-own-seats.md) govern, and code that +disagrees is what is wrong.** The implementation in progress predates several things here: seats held by assignments rather than claimed by definitions, the `mesh-vault` seat and its reservation, and the foundation's seats delivering nothing. It is brought to this table before it merges. @@ -155,16 +170,22 @@ still to take. ## How it is checked -The rules here are [ADR 0110](../../02-DECISIONS/0110-a-seat-is-a-module-assignment-from-a-closed-set.md)'s -and [ADR 0111](../../02-DECISIONS/0111-a-build-source-is-on-the-git-seat-or-external.md)'s, and each is +The rules here are [ADR 0118](../../02-DECISIONS/0118-a-module-declares-its-own-seats.md)'s — +which supersedes [ADR 0110](../../02-DECISIONS/0110-a-seat-is-a-module-assignment-from-a-closed-set.md) +and keeps every rule below except how the set is formed — and +[ADR 0111](../../02-DECISIONS/0111-a-build-source-is-on-the-git-seat-or-external.md)'s. Each is checked as their tables say: | Rule | Checked by | |---|---| -| The set is closed, and every entry names its decision | 0110: a unit test on the set's size and decisions; manifest tests refusing an unknown seat or the wrong scope. | -| A seat is held by one assignment, and only by one whose module can hold it | 0110: resolution tests for a second holder and for a seat the definition does not name. | -| A requirement naming a seat is answered by its holder; a foundation seat cannot be named | 0110: resolution tests with a second provider on the consumer's node, with the seat unheld, and naming `mesh-store`. | -| Several providers and none local is a person's choice | 0110: an assignment test listing candidates with the seat's holder first and recording the pin. | -| `secret` is reserved | 0110: the parser and resolution refusals for another provider and a pin. | -| Holdings are derived, and the overview lists every seat | 0110: the `seats` command test, including an unheld seat. | +| The set is closed, and every mesh entry names its decision | 0118: a unit test on the mesh's own entries; manifest tests refusing an unknown seat or the wrong scope. | +| The set is derived, and enumerating it is a query | 0118: the overview lists the mesh's own plus every registered module's, asserted against a fixture mesh. | +| `mesh-*` is the mesh's, and a module may not declare one | 0118: a registration test refusing a manifest that declares any `mesh-*` seat, naming the prefix. | +| Two modules cannot declare the same seat | 0118: a registration test; the second is refused and the first untouched. | +| A holder satisfies the seat's protocol | 0118: a claim whose module does not serve what the seat declares is refused at assignment. | +| A seat is held by one assignment, and only by one whose module can hold it | 0118: resolution tests for a second holder and for a seat the definition does not name. | +| A requirement naming a seat is answered by its holder; a foundation seat cannot be named | 0118: resolution tests with a second provider on the consumer's node, with the seat unheld, and naming `mesh-store`. | +| Several providers and none local is a person's choice | 0118: an assignment test listing candidates with the seat's holder first and recording the pin. | +| `secret` is reserved | 0118: the parser and resolution refusals for another provider and a pin. | +| Holdings are derived, and the overview lists every seat | 0118: the `seats` command test, including an unheld seat. | | A build source on the seat records no address; an unheld seat refuses only self-hosted builds | 0111's tests. | diff --git a/03-DESIGN/01-to-be/27-a-module-requires-the-mesh-resolves.md b/03-DESIGN/01-to-be/27-a-module-requires-the-mesh-resolves.md index 9603130..7ed81ba 100644 --- a/03-DESIGN/01-to-be/27-a-module-requires-the-mesh-resolves.md +++ b/03-DESIGN/01-to-be/27-a-module-requires-the-mesh-resolves.md @@ -8,7 +8,7 @@ decisions: - 02-DECISIONS/0115-one-assignment-of-a-module-per-node.md - 02-DECISIONS/0113-the-vault-makes-every-secret.md - 02-DECISIONS/0114-a-shared-credential-rotates-over-two-credentials.md - - 02-DECISIONS/0110-a-seat-is-a-module-assignment-from-a-closed-set.md + - 02-DECISIONS/0118-a-module-declares-its-own-seats.md - 02-DECISIONS/0111-a-build-source-is-on-the-git-seat-or-external.md - 02-DECISIONS/0084-which-provider-serves-a-consumer.md - 02-DECISIONS/0046-a-module-configuration-is-its-assignments-not-its-manifest.md @@ -64,7 +64,7 @@ Which module answers, in order: 1. **the holder of the seat the requirement names.** A requirement may name a seat instead of leaving the provider open. It asks for *the mesh's* one, and the mesh answers with whichever assignment holds that seat, with nothing asked of anyone. Unheld, the requirement is refused, naming the seat - ([ADR 0110](../../02-DECISIONS/0110-a-seat-is-a-module-assignment-from-a-closed-set.md), + ([ADR 0118](../../02-DECISIONS/0118-a-module-declares-its-own-seats.md) (superseding [ADR 0110](../../02-DECISIONS/0110-a-seat-is-a-module-assignment-from-a-closed-set.md)), [26 — The seats](26-the-seats.md)). Only a seat that delivers a provision can be named; naming a foundation seat is refused, because it delivers nothing. A `secret` requirement always names `mesh-vault`, because that provision is reserved; @@ -199,7 +199,7 @@ containers, login, broker account and settings are keyed by it, as today, and a tightest backend ([ADR 0049](../../02-DECISIONS/0049-a-consumers-identity-fits-the-tightest-backend.md)). **A module may run on many nodes, and one assignment may hold a seat** -([ADR 0110](../../02-DECISIONS/0110-a-seat-is-a-module-assignment-from-a-closed-set.md)). The definition +([ADR 0118](../../02-DECISIONS/0118-a-module-declares-its-own-seats.md) (superseding [ADR 0110](../../02-DECISIONS/0110-a-seat-is-a-module-assignment-from-a-closed-set.md))). The definition says which seats the module can hold; the assignment says which it does. So the store module can run on every node, one of those assignments holds `mesh-store`, and moving that role changes an assignment, not a definition. diff --git a/03-DESIGN/01-to-be/28-building-the-bus.md b/03-DESIGN/01-to-be/28-building-the-bus.md index 96501c5..78f1924 100644 --- a/03-DESIGN/01-to-be/28-building-the-bus.md +++ b/03-DESIGN/01-to-be/28-building-the-bus.md @@ -10,6 +10,7 @@ decisions: - 02-DECISIONS/0116-the-bus-is-built-in-five-steps.md - 02-DECISIONS/0106-the-bus-is-nats.md - 02-DECISIONS/0117-the-bus-is-the-only-broker.md + - 02-DECISIONS/0118-a-module-declares-its-own-seats.md - 02-DECISIONS/0074-the-wire-is-specified-not-the-types.md - 02-DECISIONS/0079-the-foundation-seats-are-named-after-their-servers.md - 02-DECISIONS/0100-a-node-in-use-is-adopted-before-it-is-converged.md @@ -115,6 +116,15 @@ step 5 the rollout ## Step 1 — the module, and genesis raises it +> **Revised 2026-09-26** ([ADR 0118](../../02-DECISIONS/0118-a-module-declares-its-own-seats.md), +> [design 29](29-what-a-module-declares.md)). Tasks 1.3 and 1.4 said the controller composes every +> account and creates *the four streams* at genesis, from a fixed set. That is only the mesh's own +> half. A module declares seats with their protocols, so streams are created **at registration** +> and durable consumers **at assignment** — neither of which has happened at genesis. The fixed +> foundation set stays here; the derived machinery moves to step 3, where the declaration model it +> reads from is specified. Tasks 1.1 and 1.2, already done, are untouched by this: the module and +> its reload mechanism do not care what the configuration says. + **Why here.** Everything else needs a server to talk to, and genesis is where the foundation is defined. The mesh this is for will never travel this path — it is already running, and takes step 2 — but genesis is the definition every other path is measured against, and one that exists only on @@ -126,11 +136,14 @@ paper is wrong until there is a second mesh to find out. - [ ] 1.2 the composed configuration as a **directory** resource, and the entrypoint that watches the one file and signals the server itself — design 25 §5's correction, kept inside the module because a container has no reload and a recreate would drop every connection the mesh has -- [ ] 1.3 the controller composes that file: accounts, permissions, TLS, JetStream — permissions - derived from `emits` and `consumes` and nothing else, plus each user's own ack subject and its - own inbox prefix (design 25 §4) -- [ ] 1.4 the four streams, created at genesis and asserted idempotently on start, by the controller - as their only writer +- [ ] 1.3 the controller composes that file: accounts, permissions, TLS, JetStream — a user's + permissions derived from its declaration and nothing else, over the three namespaces of + [design 29](29-what-a-module-declares.md) §2, plus its own ack subject and its own inbox + prefix (design 25 §4) +- [ ] 1.4 the mesh's own streams, created at genesis and asserted idempotently on start, by the + controller as their only writer — **the mesh's own, not all of them**: a seat's streams are + created when the module declaring it is registered, and a module's durable consumers when it + is assigned, so this task is the fixed foundation set and 3.x carries the derived rest - [ ] 1.5 genesis raises it as foundation, claiming the seat **`mesh-broker`** — the seat is the server's role, not the product - [ ] 1.6 the genesis-broker bed @@ -183,6 +196,15 @@ pays for itself furthest away. - [ ] 3.5 the host's link on NATS — mirroring, still importing nothing - [ ] 3.6 the tool runtime's client on NATS, behind the unchanged sdk contract - [ ] 3.7 the sdk's three stale comments, and nothing else in it +- [ ] 3.8 **the declaration model** of [design 29](29-what-a-module-declares.md): local names + derived to subjects, the three namespaces, permissions computed from a declaration, and a + manifest that contains no subject +- [ ] 3.9 **seats declared by modules** — registration creates a seat's streams and refuses a + `mesh-*` name, a duplicate declarer, an undeclared `uses`, and a holder that does not + satisfy the protocol; assignment creates the holder's work-queue consumer and refuses a + second holder +- [ ] 3.10 **the ten seat renames**, carried as a migration with a mapping rather than an edit, + and the beds that name seats moved with them **Done when.** The fixtures are produced and consumed byte for byte by every implementation that claims the capability, and a module built before any of this serves its tools unchanged on the new diff --git a/03-DESIGN/01-to-be/29-what-a-module-declares.md b/03-DESIGN/01-to-be/29-what-a-module-declares.md new file mode 100644 index 0000000..dfc6898 --- /dev/null +++ b/03-DESIGN/01-to-be/29-what-a-module-declares.md @@ -0,0 +1,215 @@ +--- +layer: to-be +status: proposed +code: [] +updated: 2026-09-26 +decisions: + - 02-DECISIONS/0118-a-module-declares-its-own-seats.md + - 02-DECISIONS/0117-the-bus-is-the-only-broker.md + - 02-DECISIONS/0106-the-bus-is-nats.md + - 02-DECISIONS/0041-events-are-a-relationship.md + - 02-DECISIONS/0112-a-module-definition-names-no-node-mesh-or-path.md + - 02-DECISIONS/0083-one-push-leaves-the-mesh-consistent.md +--- + +# 29. What a module declares, and what the bus makes of it + +**The bus is ambient.** No module requires it, the way no module requires a filesystem. Every +module gets a connection and an identity whether it asks or not. What a module declares are +*relationships*; subjects, streams, consumers and permissions are all derived from those, and a +manifest never contains one. + +This document is the declaration model. [Design 25](25-the-bus-on-nats.md) is the bus itself — +subjects, streams, accounts, enrolment — and stays the authority on the wire. +[Design 19](19-the-module-protocol.md) is the specification an SDK implements, and is rewritten +onto this in step 3 of [ADR 0116](../../02-DECISIONS/0116-the-bus-is-built-in-five-steps.md). + +## 1. A module names locally; the mesh derives the subject + +This is the load-bearing rule. +[ADR 0112](../../02-DECISIONS/0112-a-module-definition-names-no-node-mesh-or-path.md) says a module +definition names no node, mesh or path. A transport address is the same class of thing: if +manifests held literal subjects, reorganising the subject space would mean editing every module in +the catalogue, and the mesh would have hundreds of copies of a decision it made once. + +| declared | derived | +|---|---| +| `emits: order.placed` | publish on `mesh.mod..order.placed` | +| `consumes: billing.order.placed` | durable consumer on `mesh.mod.billing.order.placed` | +| `serves: status` | queue-group subscription on `mesh.mod..tool.status` | +| seat `telegram-sender`, `accepts: send` | work-queue consumer on `mesh.seat.telegram-sender.send` | +| `uses: telegram-sender` | publish on that seat's `accepts` subjects, and nothing else | + +**The test this must pass: the manifest survives the wire changing.** Reorganise the subject space +and every manifest in the catalogue is still correct. That is the property +[ADR 0039](../../02-DECISIONS/0039-what-the-sdk-holds-and-refuses.md) gave the sdk, applied to +declarations. + +## 2. Three namespaces, and nothing else + +**Its own** — `mesh.mod..>`. Its events and its tools. Nothing else may publish into it, +so an event's source is a fact the bus enforces rather than a claim in the body. + +**Seats it holds** — `mesh.seat..>`. Full participation: consume what the seat accepts, +publish what it emits, serve what it serves. + +**Seats it uses** — publish only, and only on the `accepts` half. A sender cannot subscribe to a +seat's inbound subject and watch other modules' traffic, and cannot publish the seat's outbound +events and lie about outcomes. + +A module naming anything outside these three is refused at registration. The whole permission set +is derivable from the declaration; nobody writes an access rule. + +## 3. Queues are derived, never declared + +A module says what it reacts to, not how delivery works. Each `consumes` becomes one durable +consumer; a seat's `accepts` becomes one work-queue consumer with a queue group named for the +seat. The module does not name them, does not know their names, and cannot misconfigure them — +and the controller stays the only writer of stream and consumer definitions +([design 25](25-the-bus-on-nats.md) §3). + +**Retention belongs to whoever owns the namespace, not to a consumer.** A seat declares how long +its inbound backlog survives, because that is a property of the service: + +``` +seat: telegram-sender + scope: mesh + accepts: send retain 7d + emits: delivered, failed + serves: status +``` + +If each consumer could tune it, the mesh's durability would be an emergent property of whichever +manifest was edited last. + +**Scope gives per-node workers without a new concept.** A module running on three nodes that each +need their own queue declares a node-scoped seat: one holder per node, three queues, same +machinery. Mesh-scoped and node-scoped seats already exist; here they do the work of "one shared +service" versus "one worker per machine". + +## 4. Five relationships + +| | provision | event | job | state | tool | +|---|---|---|---|---|---| +| shape | 1:1 resource | 1:many | N:1 | 1:1 | 1:1 | +| addressed to | a provider | the emitter's own namespace | a **seat** | one node | a module or seat | +| who must act | the provider | nobody | exactly one holder | that node | the server | +| credential | sealed, per consumer | none | none | none | none | +| reply | — | none | none, or an event later | a report | awaited | +| retention | — | age and size | work queue, explicit ack | **last per subject** | none | +| declared | `provides`/`requires` | `emits`/`consumes` | seat `accepts` / `uses` | the mesh's own | `serves` | + +**Job** is the one [ADR 0041](../../02-DECISIONS/0041-events-are-a-relationship.md) had no room +for. Its table has events at 1:many and provisions at 1:1; a module submitting work to a service +is neither. It is not an event, because an event is a broadcast nobody is obliged to act on and a +second holder would do the work twice. It is not a provision, because there is no resource and no +credential. What makes it safe is not cleverness in the subscribe call but the seat: exactly one +holder, so exactly one worker, by construction. + +**State** is the shape the deploy path needs and nothing else uses. A declaration is not an event +— replaying yesterday's is actively harmful — and not a job. Only the newest matters, which is +last-per-subject retention, and a node that has seen sequence *n* refuses *n−1* by construction. +That is the wire-level answer to +[issue 107](../../04-ISSUES/107-a-declaration-carries-no-order/00-report.md). + +## 5. Seats + +A module declares a seat with its protocol, and the mesh enforces one holder at its scope +([ADR 0118](../../02-DECISIONS/0118-a-module-declares-its-own-seats.md)). A caller declares that +it uses the *seat*, never the module, so the implementation can be replaced under it. + +- The set of seats is **derived** — the mesh's own, plus every registered module's — so it is both + closed and extensible, and enumerating it is a query rather than an inventory. +- `mesh-*` is **reserved**: the prefix is the reservation rule, and a module declaring one is + refused at registration. +- Two modules declaring the same name: the second is refused. +- A module may not claim a seat whose protocol it does not implement. +- **Nobody holding a seat is not an error.** The stream exists from registration, so work queues + until a holder appears. Install the telegram module a week later and the backlog flushes. + +## 6. The lifecycle: build, publish, deploy + +Every shape above appears once, in order, and no step knows where the next one runs. + +**A change lands.** The module holding `mesh-git` emits `pushed` — repository, ref, commit. An +event, because it is a fact about git and git's identity is the meaning. + +**The change becomes work.** The controller consumes `pushed`, asks the catalogue which modules +are built from that repository and path +([ADR 0069](../../02-DECISIONS/0069-a-module-is-a-repository-and-a-path.md)), and submits one +**job** per affected module to the `mesh-build-machine` seat. The builder stays simple: it builds +what it is handed, and never resolves anything. A builder that dies mid-build has its job +redelivered, because a work queue with explicit ack is what that means. + +**The artifact is published.** The builder pushes to the registry seats and emits `built` — +module, version, digest. An event again: a fact about the builder. + +**The build cascade is that event fanning out through a graph the mesh already has.** A module +whose image is built *on* another's artifact declares that in `build.on`. So `built` reaches the +controller, which walks the declared graph and submits rebuild jobs for everything downstream. A +dependency cascade is not special machinery — it is one event, one derived graph, and the same job +queue. + +**Deployment is state, not a message.** The controller composes each affected node's declaration +and publishes it last-per-subject. A node that was away gets exactly the current one, never a +queue of superseded ones, and a replayed older one is refused by sequence. + +**Applying is reported to a role.** The host applies and reports to the `mesh-controller` seat — +not to an address it was given at genesis. Held and retried while the store restarts +([ADR 0083](../../02-DECISIONS/0083-one-push-leaves-the-mesh-consistent.md)). + +What disappears across that chain is every address. No webhook URL, no registered callback, no +"which node is the builder on", no controller endpoint baked into a joining node. That is the +class of bug +[issue 102](../../04-ISSUES/102-an-address-recorded-at-genesis-or-build-does-not-follow-the-nodes-ports/00-report.md) +names, dissolved rather than fixed. + +## 7. Modules depending on each other + +Three kinds, and conflating them is how deployment ordering goes wrong. + +**Build-time** — A's image is built on B's artifact. Resolved by the cascade above; nothing at +runtime cares. + +**Provision** — A requires a database from B. A **hard** dependency: the credential must exist +before A can start, so resolution gates delivery and A is shown as waiting until B has answered +([design 27](27-a-module-requires-the-mesh-resolves.md)). + +**Seat** — A uses B's seat. A **soft** dependency, and this is the one the bus changes. A starts +whether or not anyone holds the seat, because the stream absorbs the gap. Deployment order stops +mattering for everything expressed this way, and a service being restarted, moved or upgraded is +not an outage for its callers — it is latency. + +That difference is worth choosing on purpose. A dependency expressed as a provision must be +ordered; the same dependency expressed as a seat need not be. + +## 8. Open + +**Protocol versioning.** A seat's protocol is a compatibility surface between modules that do not +know each other, and nothing here says what happens when it changes under callers already bound to +it. This is the first thing to answer and the one most likely to hurt in year two rather than week +one. + +**Whether a module may declare a seat it does not itself claim** — the contract as one thing, the +implementation as another, which is how two competing implementations would ever exist. + +**Whether `consumes` naming another module couples too tightly.** It is kept here deliberately — +an event's provenance is its meaning — but a consumer of `billing.order.placed` does depend on +billing existing under that name. + +## 9. How it is checked + +- **A manifest holds no subject.** A catalogue test: no manifest contains a string matching the + subject grammar. The rule is worthless if it is followed by convention. +- **Permissions are exactly the three namespaces.** A composition test per module: the derived + permission set equals what its declaration implies, and a hand-written addition to it fails. +- **A sender cannot read the queue it writes to.** A bed: a module declaring `uses` is refused + subscribe on that seat's inbound subject. +- **One holder, one delivery.** A bed: a seat's job delivered once with the holder running, and + a second claim of the seat refused. +- **A queued job survives no holder.** A bed: submit with the seat unheld, assign the holder, + the job is delivered. +- **The cascade rebuilds exactly the dependents.** A bed: publish an artifact two modules build + on, and exactly those two are rebuilt. +- **A stale declaration is refused.** A bed: replay sequence *n−1* after *n*, and the node refuses + it rather than applying it. diff --git a/03-DESIGN/01-to-be/README.md b/03-DESIGN/01-to-be/README.md index 584c25b..41f9bde 100644 --- a/03-DESIGN/01-to-be/README.md +++ b/03-DESIGN/01-to-be/README.md @@ -35,10 +35,12 @@ document is written and this one's status becomes `implemented`. | [`23-choosing-a-provider.md`](23-choosing-a-provider.md) | Which of several providers of a kind serves a consumer, and when a module carries its own instead | [ADR 0084](../../02-DECISIONS/0084-which-provider-serves-a-consumer.md), [ADR 0027](../../02-DECISIONS/0027-a-provision-names-what-the-consumer-is-coupled-to.md) | | [`24-the-secrets-vault.md`](24-the-secrets-vault.md) | The module that owns a secret — a `secret` provision, and the boundary of what it owns | [ADR 0085](../../02-DECISIONS/0085-a-secret-is-a-provision.md), [ADR 0031](../../02-DECISIONS/0031-the-control-plane-authenticates-nobody.md), [ADR 0048](../../02-DECISIONS/0048-a-provider-creates-the-credential-the-mesh-minted.md) | | [`25-the-bus-on-nats.md`](25-the-bus-on-nats.md) | **Proposed.** The architecture of the mesh's bus on NATS: what rides which subject under which guarantee and whose account, how a node joins, how a person reaches a tool, and how the mesh moves from the bus it has | [ADR 0106](../../02-DECISIONS/0106-the-bus-is-nats.md), [ADR 0116](../../02-DECISIONS/0116-the-bus-is-built-in-five-steps.md), [ADR 0043](../../02-DECISIONS/0043-a-module-broker-account-is-scoped-by-emits-and-consumes.md), [ADR 0083](../../02-DECISIONS/0083-one-push-leaves-the-mesh-consistent.md) | -| [`26-the-seats.md`](26-the-seats.md) | **Proposed.** What a mesh can have one of, who fills each, and a seat's holder answering for the provision it delivers — including the `git` seat a build's source can live on | [ADR 0110](../../02-DECISIONS/0110-a-seat-is-a-module-assignment-from-a-closed-set.md), [ADR 0111](../../02-DECISIONS/0111-a-build-source-is-on-the-git-seat-or-external.md), [ADR 0109](../../02-DECISIONS/0109-a-package-registry-seat-is-one-per-ecosystem.md) | -| [`27-a-module-requires-the-mesh-resolves.md`](27-a-module-requires-the-mesh-resolves.md) | **Proposed.** One concept for everything a module needs: a requirement with a contract, answered by one of four kinds of provider, resolved at assignment or refused. Retires settings, placeholders, facts and paths in definitions | [ADR 0112](../../02-DECISIONS/0112-a-module-definition-names-no-node-mesh-or-path.md), [ADR 0113](../../02-DECISIONS/0113-the-vault-makes-every-secret.md), [ADR 0114](../../02-DECISIONS/0114-a-shared-credential-rotates-over-two-credentials.md), [ADR 0110](../../02-DECISIONS/0110-a-seat-is-a-module-assignment-from-a-closed-set.md) | +| [`26-the-seats.md`](26-the-seats.md) | **Proposed.** What a mesh can have one of, who fills each, and a seat's holder answering for the provision it delivers — including the `git` seat a build's source can live on | [ADR 0118](../../02-DECISIONS/0118-a-module-declares-its-own-seats.md) (superseding [ADR 0110](../../02-DECISIONS/0110-a-seat-is-a-module-assignment-from-a-closed-set.md)), [ADR 0111](../../02-DECISIONS/0111-a-build-source-is-on-the-git-seat-or-external.md), [ADR 0109](../../02-DECISIONS/0109-a-package-registry-seat-is-one-per-ecosystem.md) | +| [`27-a-module-requires-the-mesh-resolves.md`](27-a-module-requires-the-mesh-resolves.md) | **Proposed.** One concept for everything a module needs: a requirement with a contract, answered by one of four kinds of provider, resolved at assignment or refused. Retires settings, placeholders, facts and paths in definitions | [ADR 0112](../../02-DECISIONS/0112-a-module-definition-names-no-node-mesh-or-path.md), [ADR 0113](../../02-DECISIONS/0113-the-vault-makes-every-secret.md), [ADR 0114](../../02-DECISIONS/0114-a-shared-credential-rotates-over-two-credentials.md), [ADR 0118](../../02-DECISIONS/0118-a-module-declares-its-own-seats.md) (superseding [ADR 0110](../../02-DECISIONS/0110-a-seat-is-a-module-assignment-from-a-closed-set.md)) | | [`28-building-the-bus.md`](28-building-the-bus.md) | **Proposed.** The five steps of the bus work in the order their dependencies allow, each ending at a bed — with the surface measured, so no step's size is a guess | [ADR 0116](../../02-DECISIONS/0116-the-bus-is-built-in-five-steps.md), [ADR 0106](../../02-DECISIONS/0106-the-bus-is-nats.md), [ADR 0074](../../02-DECISIONS/0074-the-wire-is-specified-not-the-types.md) | +| [`29-what-a-module-declares.md`](29-what-a-module-declares.md) | **Proposed.** What a module declares and what the bus derives from it: three namespaces, subjects from local names, queues never declared, the five relationships, and the build-publish-deploy lifecycle on one bus | [ADR 0118](../../02-DECISIONS/0118-a-module-declares-its-own-seats.md), [ADR 0117](../../02-DECISIONS/0117-the-bus-is-the-only-broker.md), [ADR 0041](../../02-DECISIONS/0041-events-are-a-relationship.md) | + ## Not yet written - **The remaining six contexts.**