From 50d61398b62ac091147c0f6d53e34f72b5708598 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 3 Sep 2026 21:41:08 +0200 Subject: [PATCH 01/10] =?UTF-8?q?ADR=200044=20=E2=80=94=20what=20the=20SDK?= =?UTF-8?q?=20holds,=20and=20what=20it=20refuses?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Supersedes ADR 0030's 'types, not behaviour' line for mesh-sdk. The boundary is change-frequency, not kind: the SDK holds the stable spine (tool-serving harness, messaging/event framework, contracts, core primitives) and refuses per-module clients, per-module tool code, and anything volatile — because those are what turned hal/sdk into constant maintenance and made every edit rebuild every module. States the rule (frequent AND cascading is the disease), why the root cause was intra-module feature-sharing leaking into inter-module coupling, and where per-module shared code lives instead (in the module — a shared file, or a module-local sdk for the few large ones). Updates repos.md's canonical mesh-sdk description to match; leaves 0030 untouched (immutable). Claude-Session: https://claude.ai/code/session_01LrgweAeERJYBg88c5cKDzF --- 00-META/repos.md | 2 +- .../0044-what-the-sdk-holds-and-refuses.md | 101 ++++++++++++++++++ 2 files changed, 102 insertions(+), 1 deletion(-) create mode 100644 02-DECISIONS/0044-what-the-sdk-holds-and-refuses.md diff --git a/00-META/repos.md b/00-META/repos.md index 87e954b..994e245 100644 --- a/00-META/repos.md +++ b/00-META/repos.md @@ -31,7 +31,7 @@ target, not the present. | `mesh-substrate` | 1 | the four pinned services, as declarations | | `mesh-control` | 2 | the control plane and its contexts | | `mesh-surfaces` | 3 | tools, web, cli | -| `mesh-sdk` | — | contracts shared across tiers | +| `mesh-sdk` | — | the stable spine modules build against — the tool-serving harness, the messaging/event framework, the contracts and core primitives. Holds nothing per-module and nothing volatile ([ADR 0044](../02-DECISIONS/0044-what-the-sdk-holds-and-refuses.md)). | | `mesh-lab` | — | **exists.** The lab — scenario lifecycle, networking, placement. Ships to nobody; runs on a workstation. | Tier 4's shape is open, and deliberately so: see ADR 0030 and diff --git a/02-DECISIONS/0044-what-the-sdk-holds-and-refuses.md b/02-DECISIONS/0044-what-the-sdk-holds-and-refuses.md new file mode 100644 index 0000000..5dde1f5 --- /dev/null +++ b/02-DECISIONS/0044-what-the-sdk-holds-and-refuses.md @@ -0,0 +1,101 @@ +--- +status: accepted +date: 2026-09-03 +deciders: jochen +reconstructed: false +supersedes: 0030-the-repository-structure.md +--- + +# 44. What the SDK holds, and what it refuses + +## Context + +[ADR 0030](0030-the-repository-structure.md) named `mesh-sdk` "contracts shared across tiers: +types, not behaviour." That line is superseded here, because it draws the boundary in the wrong +place. The boundary that matters is not *types versus behaviour* — it is **how often the thing +changes**. + +The current SDK is the cautionary tale, and its failure is precise. `hal/sdk` holds all the +code, including a per-module API client for every service (`clients/plex.ts`, `clients/gitea.ts`, +…) and a per-module tool implementation for each (`tools/plex.ts`, …). Every module depends on +the SDK, so **every edit to any of that per-module code rebuilds every module** — the cascade. +The SDK is under constant maintenance precisely because it became the place all the volatile +per-module logic accumulated. + +The root cause is worth stating exactly, because the fix follows from it: the pressure was never +to share a client *between* modules. It was to share a client between one module's *own features* +— plex's tools, its health check and its hooks all wanted the same `PlexClient` — and the only +place to share code across a module's features was the global SDK. So **intra-module sharing +leaked out as inter-module coupling.** + +## Decision + +The SDK holds the **stable spine** that modules build against, and earns its place by rarely +changing. The test for membership is change-frequency, not kind. + +### What it holds + +- The **tool-serving harness** — the worker and registration mechanism, and the tool-definition + type. *How* a tool is declared and served is settled; it does not change when an individual + tool does. +- The **messaging and event framework** — the broker client, the event consumer, the envelope. +- The **contracts** — the manifest, declaration, provision and link shapes. +- **Core primitives** — sealing and crypto, semver, the shared resolution helpers. + +These change rarely and deliberately. When one of them does change, a rebuild of everything is +the *correct* outcome, because the contract every module shares has genuinely changed. + +### What it must not hold — the more important half + +- **A module's API client.** A Plex client, a Gitea client, a MinIO client belong in their + module. They change when that service's API or the module's use of it changes, which is often, + and which has nothing to do with any other module. +- **A module's tool implementations.** Same reason, same place: in the module. +- **Anything volatile** — anything that changes when one service's features change. + +The rule, stated so it can be applied without re-deriving it: + +> If editing a thing recompiles unrelated modules **and** it changes often, it does not belong +> in the SDK. + +Both conditions are load-bearing. A rare change that cascades is fine — that is a contract, and +the cascade is correct. A frequent change that stays local is fine — that is a module minding its +own business. Only **frequent *and* cascading** is the disease, and per-module clients and tools +are its carriers. + +### Where per-module shared code lives instead + +Code shared among a module's *own* features lives **in the module**. The default is the plainest +thing that works: an ordinary shared file the features import — `plex/client.ts`, imported by +`plex/tools/`. Within one module, features are files importing sibling files; no package +boundary, no ceremony. + +A **module-local SDK** (a sub-package with its own version) is warranted only for the few modules +whose shared surface is large enough to version on its own. It is the exception, not the shape. + +Either form gives the property the global SDK could not: editing a module's shared code rebuilds +**that module and nothing else**. + +## Consequences + +- The cascade becomes **structurally impossible for module logic**. There is no longer an edge + from one module's internals to another, so the only thing that can rebuild everything is a real + change to a shared contract in the SDK — which is rare, and when it happens, is right. +- The SDK is small and stable **by construction**, not by discipline. Its size is no longer a + thing anyone has to police. +- **Converting a module from the current system is partly a de-coupling, not just a move.** Its + client and its tools are pulled *out* of the shared SDK and *into* the module. A conversion + that copied `clients/plex.ts` into the SDK's replacement would rebuild the exact mistake. +- The host still does not import the SDK. It depends on nothing + ([ADR 0041](0041-the-host-depends-on-nothing.md)) and **mirrors** the contracts rather than + importing them, exactly as its apply-shapes table already does deliberately. The SDK is shared + by the tiers that *can* share code; the host is not one of them. + +## References + +- [ADR 0030](0030-the-repository-structure.md) — named the repositories; its `mesh-sdk` + description ("types, not behaviour") is superseded by this record. +- [ADR 0015](0015-mesh-brokers-nodes-host-agents-think.md) — the decomposition this serves: code + belongs to the boundary that owns it. +- [ADR 0041](0041-the-host-depends-on-nothing.md) — why the host mirrors the contracts instead of + importing the SDK. From b2cb4816818c57fdf2458712d56b6379a18f4b8f Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 3 Sep 2026 22:50:52 +0200 Subject: [PATCH 02/10] =?UTF-8?q?ADR=200045=20=E2=80=94=20what=20a=20modul?= =?UTF-8?q?e=20is?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A module is one self-contained piece of software the mesh installs and manages; the software is its identity, and capabilities/seats/provisions are the relationships between modules, not what a module is. Records the three relationships (shared seat, exclusive seat, provide/require), that interfaces are mesh-owned and providers adapt to them, and the naming rule: draw the interface at the consumer's real coupling — neutral where the coupling is thin (analytics), protocol-scoped where the consumer speaks a protocol (postgres/mssql/mongodb), never false genericity. Supersedes 0017 (domain grouping — wrong axis), refines 0002, generalises 0027's protocol-not-product rule. Claude-Session: https://claude.ai/code/session_01LrgweAeERJYBg88c5cKDzF --- 02-DECISIONS/0045-what-a-module-is.md | 98 +++++++++++++++++++++++++++ 1 file changed, 98 insertions(+) create mode 100644 02-DECISIONS/0045-what-a-module-is.md diff --git a/02-DECISIONS/0045-what-a-module-is.md b/02-DECISIONS/0045-what-a-module-is.md new file mode 100644 index 0000000..0cdebd9 --- /dev/null +++ b/02-DECISIONS/0045-what-a-module-is.md @@ -0,0 +1,98 @@ +--- +status: accepted +date: 2026-09-03 +deciders: jochen +reconstructed: false +supersedes: 0017-modules-outside-the-core-are-grouped-by-domain.md +extends: 0002-everything-is-a-module.md +--- + +# 45. What a module is + +## Context + +[ADR 0002](0002-everything-is-a-module.md) settled that everything is a module, but never said what a +module *is* beyond "a directory the mesh processes." That gap let the catalogue's breadth read as a +smell: a module can carry a container, a built image, tools, a provisioner, migrations, health, +config, seat claims, requires and provides — so much that the unit seemed ill-defined. +[ADR 0017](0017-modules-outside-the-core-are-grouped-by-domain.md) tried to organise modules by +domain, which is the wrong axis. This record states what a module is, drawn from the cases that +stress-tested it: the shell, i3-vs-sway, umami, and "database." + +## Decision + +**A module is one self-contained piece of software the mesh installs and manages** — everything +needed to make that one thing real and integrable: what runs, the seats it claims, what it provides +to other modules, what it requires from them, and what operates it. + +The **software is the module's identity.** Capabilities, seats and provisioned resources are the +**relationships *between* modules**, not what a module is — and that is what binds a module into one +thing. umami is bound by *being umami*: its container runs umami, its provisioner creates umami sites, +its tools query umami, its `requires` gets umami a database. Every feature serves the one software. + +### The three relationships + +1. **Shared seat** — several modules fulfil a capability and coexist; one may be default. bash, zsh + and fish all join `shell`. +2. **Exclusive seat** — modules contend for a single slot; one holds it. i3 (needs x11) and sway + (needs wayland) contend for `display-session`. +3. **Provide / require** — a provider ships the **provisioner** that creates instances of the + resource it offers and returns sealed credentials; a consumer requires it and the mesh wires the + credential in. Symmetric: umami requires a database *and* provides analytics. + +### Interfaces are mesh-owned; providers adapt to them + +The mesh **defines the interface** for a capability — the provider-neutral contract of what a +consumer receives and how it integrates. Both sides conform: a provider's provisioner **adapts** its +software's real API to the mesh contract; a consumer depends on the **interface**, never on a +provider. Swap one provider for another and the consumer does not change. + +### The naming rule — draw the interface at the consumer's real coupling + +Name a `provides`/`requires` at the **widest boundary across which the consumer genuinely does not +care which implementation serves it**: + +- Where the consumer's coupling is thin — an analytics embed snippet and dashboard, opaque to it — + the mesh defines a neutral interface (`analytics`) and providers (umami, amumi) adapt. Swappable + across vendors. +- Where the consumer **speaks a protocol** — a database's wire protocol and query dialect — the + interface *is* the protocol: `postgres-database`, `mssql-database`, `mongodb-database`. Swappable + only among protocol-compatible implementations, **never across**, because the application cannot + cross it either. "database" is not a capability; the protocol is. +- **Never false genericity.** A name must not promise a swap the contract cannot deliver + ([research 005](../01-RESEARCH/005-domain-grouping/analysis.md)). + +This is [ADR 0027](0027-the-product-is-novox-mesh.md)'s rule made general — "names the protocol, not +the product; a database names the engine because the app targets it" — with the reason stated: the +contract sits where the coupling is. + +### What is not a module + +- A **library** (built against, never deployed — [ADR 0044](0044-what-the-sdk-holds-and-refuses.md)). +- A **control-plane context** (the mesh itself — [ADR 0015](0015-mesh-brokers-nodes-host-agents-think.md)). + +A **swappable machine mechanism** (a firewall — ufw, nftables) *is* a module implementing a +capability. The host hardcodes no firewall, supervisor, package manager or runtime; it owns only the +generic apply primitives and platform detection, so it runs where none of those exist — an Android +phone has no ufw, systemd, pacman or Docker. + +## Consequences + +- **Supersedes [ADR 0017](0017-modules-outside-the-core-are-grouped-by-domain.md).** Modules are + organised by their relationships (seats, provisions), not grouped into domain folders. +- **Refines [ADR 0002](0002-everything-is-a-module.md).** Everything the mesh runs and integrates is + a module — but a module is defined by the *software it delivers*, not by being a bucket of features. +- The target is a **self-fulfilling mesh**: declared wants bound to swappable modules, provisioners + wiring credentials, nothing hardcoded. The control plane's whole job is the binding. +- Converting a module from the old system includes pulling its per-module code out of the shared SDK + ([ADR 0044](0044-what-the-sdk-holds-and-refuses.md)) and shipping its provisioner as an adapter to a + mesh interface — a de-coupling, not just a move. + +## References + +- [ADR 0002](0002-everything-is-a-module.md) — everything is a module; this says what one is. +- [ADR 0015](0015-mesh-brokers-nodes-host-agents-think.md) — contexts are the mesh, not modules. +- [ADR 0017](0017-modules-outside-the-core-are-grouped-by-domain.md) — superseded. +- [ADR 0027](0027-the-product-is-novox-mesh.md) — protocol-not-product, generalised here. +- [ADR 0044](0044-what-the-sdk-holds-and-refuses.md) — per-module code lives in the module. +- [research 011](../01-RESEARCH/011-the-module-graph/00-overview.md) — the graph of these relationships. From 4bb6dd26edcd81245a320792f03e1cea08658766 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 3 Sep 2026 23:42:25 +0200 Subject: [PATCH 03/10] =?UTF-8?q?ADR=200046=20=E2=80=94=20events=20are=20a?= =?UTF-8?q?=20relationship,=20provisioning's=20lighter=20sibling?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A module emits and consumes events, both declared (emits/consumes), parallel to provides/requires. Events are 1:many, broadcast, credential- free — no provisioner, just the broker's topic routing — so most inter- module reaction should be an event, not a provision. Every event carries source/node/time so it is auditable; the audit logger is just a module consuming '#', no privilege. A consumes for an event nothing emits is a dangling edge and refused, like requires. One per-node runtime serves tools, provisioning and events alike. Extends ADR 0045; builds on ADR 0001 (the broker) and 0044 (emit/on are stable sdk surface; the binding and runtime are not). Claude-Session: https://claude.ai/code/session_01LrgweAeERJYBg88c5cKDzF --- .../0046-events-are-a-relationship.md | 84 +++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 02-DECISIONS/0046-events-are-a-relationship.md diff --git a/02-DECISIONS/0046-events-are-a-relationship.md b/02-DECISIONS/0046-events-are-a-relationship.md new file mode 100644 index 0000000..4c5cb97 --- /dev/null +++ b/02-DECISIONS/0046-events-are-a-relationship.md @@ -0,0 +1,84 @@ +--- +status: accepted +date: 2026-09-03 +deciders: jochen +reconstructed: false +extends: 0045-what-a-module-is.md +--- + +# 46. Events are a relationship, the lighter sibling of provisioning + +## Context + +[ADR 0045](0045-what-a-module-is.md) names two relationships between modules — seats and +provide/require (provisioning). A third is latent in the mesh and worth making first-class: the +broker every node already runs ([ADR 0001](0001-nodes-communicate-over-a-broker.md)) can carry a +module's activity as **events**, which any other module reacts to. A logger that writes an audit +trail, a module that acts when another module acts, observability — all of it is one mechanism, and +today it is ambient rather than declared. + +## Decision + +**A module emits events and consumes events, and both are declared** — parallel to `provides` / +`requires`, so the mesh knows the event graph the same way it knows the provisioning graph. + +### Events are provisioning's lighter sibling + +| | provisioning | events | +|---|---|---| +| shape | **1:1**, a provider creates a resource *for* one consumer | **1:many**, a module emits, any number listen | +| credential | yes — sealed, per consumer | none — it is broadcast | +| machinery | a provisioner (the reconcile adapter) | nothing but the broker's topic routing | +| declared as | `provides` / `requires` | `emits` / `consumes` | + +Because an event is broadcast and credential-free, there is no provisioner and no per-consumer +setup — only a subscription. That is why it is the *lighter* relationship, and why most +inter-module reaction should be an event, not a provision. + +### An event carries what an audit needs + +Every event carries its **type** (a dotted topic key, so listeners match by prefix), its **source** +module, the **node** it came from, and the **time**. A body follows. The metadata is not optional: +a reaction may only need the body, but an audit trail needs to know who did what, where and when, +and an event that cannot answer that is not auditable. + +### The audit logger is just a consumer of everything + +A logger that records the whole mesh's activity is **not a privileged component** — it is an +ordinary module that consumes `#` (every event) and writes them down. It holds no special access; +it only listens widely. That it falls out of the model with no new machinery is the check that the +model is right. + +### `consumes` is validated like `requires` + +A `consumes` for an event that **nothing** `emits` is a dangling edge, and the mesh refuses it +before deploy — the same rule that catches a `requires` for a resource nothing provides +([research 011](../01-RESEARCH/011-the-module-graph/00-overview.md)). A listener waiting for an +event that can never arrive is a silent failure, and this repository's whole discipline is against +silent failure. + +### One runtime serves all three + +The per-node module runtime that serves a module's tools also wires its `consumes` (subscribe, +dispatch to the handler) and lets its code `emit`. Tools are *invoked* (request/reply), resources +are *provisioned* (1:1, credentialed), events are *emitted and consumed* (1:many, broadcast) — +three relationships, one broker, one runtime, all declared on the manifest. + +## Consequences + +- The mesh gains a declared **event graph** alongside the provisioning graph — visible, validated, + reasoned over. +- **Reaction becomes the default coordination**: a module acts on another's event without either + knowing the other, and without a credentialed link. Coupling drops. +- An **audit trail** is a module, not a platform feature — and can be swapped, extended or run more + than once (a file logger and a queryable one) with no change to anything that emits. +- 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. + +## References + +- [ADR 0001](0001-nodes-communicate-over-a-broker.md) — the broker events ride. +- [ADR 0045](0045-what-a-module-is.md) — the relationships this extends. +- [ADR 0044](0044-what-the-sdk-holds-and-refuses.md) — `emit`/`on` are stable sdk surface; the + broker binding and the runtime are not. +- [research 011](../01-RESEARCH/011-the-module-graph/00-overview.md) — the graph these edges join. From 820ce8fc8c713827da7e396306781ff6b5747ca8 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 3 Sep 2026 23:47:58 +0200 Subject: [PATCH 04/10] =?UTF-8?q?ADR=200047=20=E2=80=94=20the=20shape=20of?= =?UTF-8?q?=20an=20event=20on=20the=20wire?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The wire contract ADR 0046 left open: two topic exchanges (mesh.events, mesh.rpc, kept apart so # is a clean audit); the routing key as the event type namespaced by origin (module.*, mesh.*, node.*); metadata in AMQP headers (required x-event-id/x-source/x-node/x-time/content-type; optional x-causation-id/x-schema; unknown x- headers ignored) with the body only the payload; persistent messages; per-consumer durable dead-lettered queues with prefetch; at-least-once with idempotent consumers (no false exactly- once). The precedent is ADR 0043 for declarations. Supersedes the sdk's first cut (metadata in body -> headers); that and the queue config are code to align in mesh-sdk and mesh-tools. Claude-Session: https://claude.ai/code/session_01LrgweAeERJYBg88c5cKDzF --- .../0047-the-shape-of-an-event-on-the-wire.md | 115 ++++++++++++++++++ 1 file changed, 115 insertions(+) create mode 100644 02-DECISIONS/0047-the-shape-of-an-event-on-the-wire.md diff --git a/02-DECISIONS/0047-the-shape-of-an-event-on-the-wire.md b/02-DECISIONS/0047-the-shape-of-an-event-on-the-wire.md new file mode 100644 index 0000000..c3ec99e --- /dev/null +++ b/02-DECISIONS/0047-the-shape-of-an-event-on-the-wire.md @@ -0,0 +1,115 @@ +--- +status: accepted +date: 2026-09-03 +deciders: jochen +reconstructed: false +extends: 0046-events-are-a-relationship.md +--- + +# 47. The shape of an event on the wire + +## Context + +[ADR 0046](0046-events-are-a-relationship.md) made events a relationship — `emits`/`consumes`, the +graph, the audit logger. It did not say what an event *is* on the broker: the exchanges, the +routing keys, the headers, the queues and their configuration. That shape is a contract every +emitter and consumer conforms to, exactly as [ADR 0043](0043-a-declaration-is-an-ordered-list-of-owned-resources.md) +is for declarations — and it was being decided ad-hoc in code. This settles it, so the sdk and the +runtime implement one contract and a module never reinvents it. + +## Decision + +### Two exchanges, kept apart + +- **`mesh.events`** — a durable topic exchange. Every event rides it: module, mesh and node. +- **`mesh.rpc`** — a durable topic exchange. Tool invocations (request/reply) ride it. + +Kept separate because RPC is not an event: a `#` subscription on `mesh.events` is then a complete +audit of what happened, with none of the invocation traffic. + +### The routing key is the event type, namespaced by origin + +Dotted and hierarchical — `..` — with three reserved origins: + +- `module..` — `module.umami.site.created` +- `mesh..` — `mesh.delivery.deployed`, `mesh.provisioning.granted` +- `node..` — `node.anchor.joined`, `node.anchor.unreachable` + +Topic matching gives a consumer `node.*.joined`, `module.umami.#`, or `#`. The origin roots are +reserved; everything after is the emitter's own namespace. + +### Metadata in headers, payload in the body + +An event's identity and provenance are AMQP **headers**, so a consumer — or the broker, or an +audit tool — reads who/when/what without parsing the body, and the body is only the domain payload. + +**Required headers** + +| header | meaning | +|---|---| +| `x-event-id` | a unique id — for dedup and audit (delivery is at-least-once, below) | +| `x-source` | the emitter: the module, context or node name | +| `x-node` | the node it was emitted from | +| `x-time` | emit time, RFC-3339 | +| `content-type` | `application/json` | + +**Optional headers** + +| header | meaning | +|---|---| +| `x-causation-id` | the event or command that caused this one — tracing | +| `x-schema` | a version of the body's shape, so a body evolves without silent misreads | + +The routing key already carries the type; it is not duplicated as a header. An **unknown `x-` +header is ignored, not refused** — unlike a declaration, an event is observed by parties that need +not all understand every header, and refusing would couple every consumer to every emitter's +additions. + +### Messages are persistent + +Events are published persistent (delivery-mode 2). An audit trail that loses events on a broker +restart is not one, and the cost is disk the broker already spends on everything durable. + +### Queues: one per consumer, durable, dead-lettered + +- **A consumer's queue** is `..events`, durable, bound to that module's consumed + patterns. Durable so a restart does not drop what arrived while it was down. **Manual ack** after + the handler succeeds — at-least-once. +- **Prefetch** bounds in-flight work (default 32) so one slow consumer does not pull the whole + backlog into memory. +- **A dead-letter exchange** `mesh.events.dead` receives a message rejected past a redelivery limit, + so a poison event is set aside for inspection rather than looping forever or vanishing silently. +- **The audit logger's queue** `.audit-logger.events`, bound to `#`, is the same shape — + durable, persistent, dead-lettered — because completeness is its whole job. +- **RPC reply queues** are exclusive, auto-delete and server-named; **RPC serve queues** + `serve.` are durable and shared, so several runtimes serving one tool key compete rather than + each answer. + +### At-least-once, and consumers are idempotent + +A handler may see an event twice — a redelivery after a crash between doing the work and acking. +Consumers must be idempotent, and `x-event-id` is what makes dedup possible. **Exactly-once is not +offered**: it is a promise no broker keeps honestly, and saying so is better than pretending. + +## Consequences + +- The event shape is a versioned, enforced contract, not conventions each module reinvents. The + sdk's `emit`/`on` and the runtime's AMQP binding implement it; a module never sees an exchange or + queue name. +- Metadata-in-headers means the body is exactly the domain payload, and a consumer that only wants + provenance never parses it. +- Adding a header or an origin root widens the contract and is reviewed as one — the discipline + [ADR 0043](0043-a-declaration-is-an-ordered-list-of-owned-resources.md) applies to the + declaration vocabulary. +- The sdk's first cut carried source/node/time in the *body*; this supersedes that — they move to + headers. That is code to align, in `mesh-sdk` (`emit`/`on`) and `mesh-tools` (the binding, queue + config, dead-letter). + +## References + +- [ADR 0046](0046-events-are-a-relationship.md) — events as a relationship; this is their wire shape. +- [ADR 0043](0043-a-declaration-is-an-ordered-list-of-owned-resources.md) — the precedent: a wire + contract, versioned, additions reviewed as security. +- [ADR 0001](0001-nodes-communicate-over-a-broker.md) — the broker. +- [ADR 0044](0044-what-the-sdk-holds-and-refuses.md) — `emit`/`on` are stable sdk surface; the + binding, queue config and dead-letter are the runtime's, not the sdk's. From b622de4fe627e23ae38e2e876137cdadff714f52 Mon Sep 17 00:00:00 2001 From: jochen Date: Fri, 4 Sep 2026 01:19:15 +0200 Subject: [PATCH 05/10] =?UTF-8?q?ADR=200048=20=E2=80=94=20a=20module's=20b?= =?UTF-8?q?roker=20account=20is=20scoped=20by=20its=20emits=20and=20consum?= =?UTF-8?q?es?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Events (0046) and their wire (0047) left open how a module reaches the broker. The code has no generic module broker-account: only node and builder scopes exist, so emits/consumes are enforced by nothing — a manifest declaring a scope the broker does not draw (04-ISSUES/003). Decides: on assign, a module gets a broker account whose permissions ARE the manifest — read on mesh.events + its own queue bound to consumes; write to mesh.events under module..* only; nothing else. Consuming '#' is a deliberate, auditable grant. The account is what makes the declaration a rule the broker enforces, not a comment. Claude-Session: https://claude.ai/code/session_01LrgweAeERJYBg88c5cKDzF --- ...account-is-scoped-by-emits-and-consumes.md | 99 +++++++++++++++++++ 1 file changed, 99 insertions(+) create mode 100644 02-DECISIONS/0048-a-module-broker-account-is-scoped-by-emits-and-consumes.md diff --git a/02-DECISIONS/0048-a-module-broker-account-is-scoped-by-emits-and-consumes.md b/02-DECISIONS/0048-a-module-broker-account-is-scoped-by-emits-and-consumes.md new file mode 100644 index 0000000..583cc57 --- /dev/null +++ b/02-DECISIONS/0048-a-module-broker-account-is-scoped-by-emits-and-consumes.md @@ -0,0 +1,99 @@ +--- +status: proposed +date: 2026-09-04 +deciders: jochen +extends: 0046-events-are-a-relationship.md +--- + +# 48. A module's broker account is scoped by what it emits and consumes + +## Context + +[ADR 0046](0046-events-are-a-relationship.md) made events a relationship — `emits` and `consumes` +on the manifest. [ADR 0047](0047-the-shape-of-an-event-on-the-wire.md) gave them a wire shape — the +`mesh.events` exchange, the durable per-consumer queue, the reserved routing-key origins. Neither +said how a module *reaches* the broker: what account it holds, and what that account is allowed to +do. + +As the code stands, there is no answer. The mesh can provision a **node** account (at enrolment) +and a **builder** account (scoped to the build queue), and it can *deliver* any module a sealed +own-secret at a declared path — but it has no way to provision a broker **account** for a general +module. A module that declares `own-secrets: {broker: …}` and nothing more receives thirty-two +random bytes, not a credential. So on the broker, `emits` and `consumes` are enforced by nothing: a +running module could bind any queue, consume any pattern, and publish under any origin, and the +manifest that says otherwise would be describing a boundary no code draws — the exact shape of fault +[04-ISSUES/003](../04-ISSUES/003-firewall-scope-is-read-by-no-code/00-report.md) records, a scope +declared in manifests and read by nothing. + +This settles it, so a module's place on the bus is a thing the broker enforces rather than a thing +the manifest merely claims. + +## Decision + +### A module gets a broker account when it is assigned, and its permissions are the manifest + +When the mesh assigns a module to a node it provisions a broker account for that module on that node, +sealed to the node ([ADR 0039](0039-the-link-is-the-security-boundary.md)) and delivered as the +module's `own-secrets` broker — `amqps://` with the mesh's fingerprint, the shape +[ADR 0047](0047-the-shape-of-an-event-on-the-wire.md) already carries. The account's permissions are +derived from the manifest, and are exactly these: + +- **What it consumes.** Read on `mesh.events`, and configure-and-read on its own queue + `..events` bound to the patterns in `consumes`. It cannot bind or read another + module's queue. A module that consumes nothing gets no read on the events exchange at all. +- **What it emits.** Write to `mesh.events`, restricted to routing keys under its own origin, + `module..*`. It cannot publish as another module, and cannot publish under the reserved + `mesh.*` or `node.*` origins — those belong to the mesh and the host (ADR 0047). A module that + emits nothing gets no write. +- **Nothing else.** The events account reaches `mesh.events` and that module's own queue, and no + more. Tool serving and calling over `mesh.rpc` is a separate grant on the same principle — a + module serves the tool keys it declares and calls the ones it is bound to — and is scoped the same + way rather than folded in here. + +### Consuming everything is a privilege, granted deliberately + +`consumes: ["#"]` — the audit logger — is read across the whole bus: every module's events, the +mesh's, every node's. That is not a pattern like any other; it is the power to see everything, and +the account is where it becomes visible. The grant that lets one module read the entire bus is one +the mesh issues on purpose and can be audited — the answer to *who can read everything* is a row, not +a guess — rather than a breadth any manifest acquires by typing a single character. A `#` consume is +a reviewed grant, not a default one. + +### The account is how the declaration is enforced + +Because the account can do only what `emits` and `consumes` name, the broker itself refuses a module +that tries to consume a queue it did not declare or emit under an origin it does not own. That is what +makes an event relationship a rule and not a comment — the discipline that a stated rule says how it +is checked. A manifest that over-declares grants more than the module uses, which is visible and +reviewable; one that under-declares makes the module fail closed at the broker, which is the safe +direction to be wrong in. + +## Consequences + +- The control plane gains a **generic module broker-account**, derived from the manifest. The + builder stops being a special case: its access to the build queue becomes an ordinary expression of + what it consumes and serves, not a bespoke account method. One rule, and the builder is an instance + of it. +- The runtime reads its credential from a file (the broker own-secret), `amqps://` verified against + the mesh's fingerprint. The `guest` account is for raising the substrate, never for a module — a + module documented as holding its own credential and handed the broker's administrative one is worse + than one with no credential story at all. +- `emits` and `consumes` stop being advisory. They are the module's authority on the bus, so the + manifest is now a security boundary and is reviewed as one, the discipline + [ADR 0043](0043-a-declaration-is-an-ordered-list-of-owned-resources.md) applies to the declaration + vocabulary. +- *Who can read the whole bus* becomes an answerable question, because `#` is a grant and not an + accident. + +## References + +- [ADR 0046](0046-events-are-a-relationship.md) — events are a relationship; this scopes the account + by that relationship. +- [ADR 0047](0047-the-shape-of-an-event-on-the-wire.md) — the wire this account secures: the queue, + the origins, the `amqps` credential shape. +- [ADR 0039](0039-the-link-is-the-security-boundary.md) — the link is the security boundary; a + module's account is sealed to its node the same way a node's is. +- [ADR 0043](0043-a-declaration-is-an-ordered-list-of-owned-resources.md) — a declaration is owned + and its additions reviewed; a module's broker permissions are that discipline applied to the bus. +- [04-ISSUES/003](../04-ISSUES/003-firewall-scope-is-read-by-no-code/00-report.md) — a scope declared + in manifests and enforced by no code: the fault this decision closes for events. From cfa3ad19303073b74af66bba4f288b5d0f8dad50 Mon Sep 17 00:00:00 2001 From: jochen Date: Fri, 4 Sep 2026 01:22:47 +0200 Subject: [PATCH 06/10] =?UTF-8?q?ADR=200048=20=E2=80=94=20ratified:=20stat?= =?UTF-8?q?us=20accepted?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...-a-module-broker-account-is-scoped-by-emits-and-consumes.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/02-DECISIONS/0048-a-module-broker-account-is-scoped-by-emits-and-consumes.md b/02-DECISIONS/0048-a-module-broker-account-is-scoped-by-emits-and-consumes.md index 583cc57..38576bd 100644 --- a/02-DECISIONS/0048-a-module-broker-account-is-scoped-by-emits-and-consumes.md +++ b/02-DECISIONS/0048-a-module-broker-account-is-scoped-by-emits-and-consumes.md @@ -1,7 +1,8 @@ --- -status: proposed +status: accepted date: 2026-09-04 deciders: jochen +reconstructed: false extends: 0046-events-are-a-relationship.md --- From 5118258ee3dff8a2195ae6acaeba8b4cadc584d4 Mon Sep 17 00:00:00 2001 From: jochen Date: Fri, 4 Sep 2026 20:45:58 +0200 Subject: [PATCH 07/10] =?UTF-8?q?ADR=200049=20and=200050=20=E2=80=94=20pub?= =?UTF-8?q?lic=20DNS=20and=20the=20firewall,=20two=20reachability=20decisi?= =?UTF-8?q?ons?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 0049: a public name is provisioned like any capability — a module requires public-dns and contributes its host; a neutral interface answered by registrar-scoped providers (cloudflare-dns, route53-dns) that create/remove the record pointing the name at the mesh's public ingress. Pairs with route (the proxy) and a public cert (the proxy's ACME). 0050: answers the firewall question. The firewall is NOT a provider like the proxy — it is a machine's own filter, derived by the host as the sum of what its modules declare they listen on, with 'from' the whole of public-vs-internal. Enforced both ways, unknown keys refused — closing 04-ISSUES/003. A public service is exposed through the proxy (listens from:mesh + requires route), not by opening its own port. Claude-Session: https://claude.ai/code/session_01LrgweAeERJYBg88c5cKDzF --- ...name-is-provisioned-like-any-capability.md | 93 +++++++++++++++++++ ...rewall-is-the-sum-of-what-it-listens-on.md | 92 ++++++++++++++++++ 2 files changed, 185 insertions(+) create mode 100644 02-DECISIONS/0049-a-public-name-is-provisioned-like-any-capability.md create mode 100644 02-DECISIONS/0050-a-machine-firewall-is-the-sum-of-what-it-listens-on.md diff --git a/02-DECISIONS/0049-a-public-name-is-provisioned-like-any-capability.md b/02-DECISIONS/0049-a-public-name-is-provisioned-like-any-capability.md new file mode 100644 index 0000000..f0eefcb --- /dev/null +++ b/02-DECISIONS/0049-a-public-name-is-provisioned-like-any-capability.md @@ -0,0 +1,93 @@ +--- +status: proposed +date: 2026-09-04 +deciders: jochen +reconstructed: false +extends: 0005-capabilities-are-provisioned-on-declaration.md +--- + +# 49. A public name is provisioned, not registered by hand + +## Context + +The mesh names and resolves its own machines internally: the overlay generates +`..` wildcards, dnsmasq answers them (`wildcard-resolution`), and the mesh +issues a certificate for each internal name. A service reachable at a *public* domain — +`plex.example.com`, not `plex.anchor.internal` — needs three things that machinery does not give it: + +- a **public DNS record** at a registrar or DNS provider, so the name resolves on the internet; +- a **publicly-trusted certificate** for it, because the mesh's own authority is trusted by nobody + outside the mesh; +- and routing from that name to the module — which the reverse proxy already does: a module + `requires` the `route` capability and the proxy provides it, routing by the host it was asked for. + +The routing exists. The public DNS record does not: the mesh has no way to make a name resolve on +the public internet, so today that is a step someone does by hand at a DNS provider, outside the +mesh, remembered nowhere. A public name is therefore the one part of reaching a service that the +declaration graph cannot grant or withdraw — which means it is created once and outlives whatever it +was for, the shape of drift this project exists to remove. + +## Decision + +### A public name is a capability, requested like any other + +A module reachable at a public host declares `requires: ["public-dns"]` and contributes the hostname +it wants — beside `requires: ["route"]`, which exposes it through the proxy. The name is then +provisioned on declaration ([ADR 0005](0005-capabilities-are-provisioned-on-declaration.md)): created +when the module is assigned, removed when it is withdrawn, reconciled like every provision. + +### The interface is neutral; the providers are the registrars + +`public-dns` is drawn at the consumer's coupling: the consumer wants *a public name that resolves to +me*, and does not care whether Cloudflare, Route 53 or a registrar's own API puts the record there. +So the interface is neutral and the providers are provider-scoped — `cloudflare-dns`, +`route53-dns`, `porkbun-dns` — each implementing the one `public-dns` contract, the same way a +neutral database coupling is answered by `postgres-database` and `mssql-database`. A module names +`public-dns`; it never names a registrar. + +### The record points at the mesh's public ingress, not at the node + +What the name resolves to is the address the reverse proxy answers on, not the consuming machine's. +A public service is reachable only *through* the proxy — the proxy holds the `route` grant and routes +by host to the module — so the public name must resolve to the proxy. `public-dns` and `route` are +the two halves of one public exposure: the name, and what the name reaches. + +### The record is a fact, not a secret + +A DNS record is public by definition, so the grant returns the fully-qualified name and its TTL and +nothing sealed. The only secret is the provider's own API credential, which is the provider module's +own-secret and never leaves it — the module that wanted the name never sees it. + +### Events + +The provider emits `module..record.created` and `module..record.removed` +([ADR 0046](0046-events-are-a-relationship.md)), so *which names the mesh publishes, and where* is a +question answered from the event trail and the grants, not from a folder of records edited at a +provider. + +### The public certificate is the proxy's, and is named here only to pair it + +A public name without a publicly-trusted certificate is reachable and not trusted — the same pairing +the internal name and the mesh-issued certificate already have. Obtaining that certificate (ACME +against the now-resolving public name) is the reverse proxy's to do, and its mechanism is its own +decision; it is named here so the pairing is not forgotten, not resolved here. + +## Consequences + +- A public name is created and torn down with the module, so it cannot outlive it, and the mesh can + say which public names it publishes without anyone reading a registrar's dashboard. +- Adding a registrar is adding a provider that answers `public-dns`; the modules that want names do + not change. +- Public exposure of a service is a trio of separate, declared, enforced relationships: the firewall + opens the proxy's public port ([ADR 0050](0050-a-machine-firewall-is-the-sum-of-what-it-listens-on.md)), + `route` routes the host to the module, and `public-dns` makes the host resolve. + +## References + +- [ADR 0005](0005-capabilities-are-provisioned-on-declaration.md) — a capability is provisioned on + declaration; a public name is one. +- [ADR 0050](0050-a-machine-firewall-is-the-sum-of-what-it-listens-on.md) — the firewall, the other + half of the reachability question this was asked with. +- [ADR 0046](0046-events-are-a-relationship.md) — the provider's record events. +- [ADR 0045](0045-what-a-module-is.md) — a provider and its interface; the neutral-interface, + scoped-provider naming this follows. diff --git a/02-DECISIONS/0050-a-machine-firewall-is-the-sum-of-what-it-listens-on.md b/02-DECISIONS/0050-a-machine-firewall-is-the-sum-of-what-it-listens-on.md new file mode 100644 index 0000000..412d159 --- /dev/null +++ b/02-DECISIONS/0050-a-machine-firewall-is-the-sum-of-what-it-listens-on.md @@ -0,0 +1,92 @@ +--- +status: proposed +date: 2026-09-04 +deciders: jochen +reconstructed: false +extends: 0037-the-host-applies-it-does-not-decide.md +--- + +# 50. A machine's firewall is the sum of what its modules listen on + +## Context + +The reverse proxy is a *provider*: a module `requires` the `route` capability and a running proxy +provides it, routing traffic by name and reaching back to the consumer. A fair question follows — +is the firewall the same shape? Should a module *register* a port with a firewall provider the way +it requests a route? + +It should not, and the difference is the point. A reverse proxy is a service another component +performs; a firewall is a property of the machine — a packet filter the host applies to itself. +Modelling it as a provider would invent a credential and a reach-back for something that has neither. + +And the mesh already has the registration: a module declares `listens: [{ port, from }]` — the port +it accepts connections on, and from where. That *is* how a service says it wants a port open. What is +missing is not a model but enforcement. [04-ISSUES/003](../04-ISSUES/003-firewall-scope-is-read-by-no-code/00-report.md) +records that a `scope:` key five manifests carry is read by no code: a manifest can appear to +restrict a port and restrict nothing — the exact fault +[how-we-build.md](../00-META/how-we-build.md) names, *an unenforced rule is indistinguishable from a +wrong one*, made worse because the declaration reads as a restriction. + +## Decision + +### The firewall is derived and host-applied, not a provider + +A machine's firewall is the sum of what the modules assigned to it declare they listen on, computed +by the host and applied as one of its owned resources ([ADR 0037](0037-the-host-applies-it-does-not-decide.md): +the host applies, it does not decide; [ADR 0043](0043-a-declaration-is-an-ordered-list-of-owned-resources.md): +the declaration is owned resources). It is not a capability, not a per-consumer grant — opening a +port is a declarative fact about a machine, so it is computed and applied, not requested and +credentialed. + +### `from` is the whole of public-versus-internal + +The distinction the question is really about lives in `from`: + +- `listens: [{ port: 5432, from: mesh }]` — open to the private overlay only. +- `listens: [{ port: 443, from: anywhere }]` — open to the public internet. + +A module registers a port on the firewall by listening on it and saying from where. There is no +separate firewall capability, because the firewall is not a thing that reaches back or holds a +secret; it is the machine's own filter over the ports its modules named. + +### The host enforces it both ways, and unknown keys are refused + +A port a module listens on is opened to exactly the scope it named; a port nothing declares is +closed. And a key the firewall does not read — the `scope:` of issue 003 — is refused at the +manifest, not accepted and ignored, so a declaration that reads as a restriction is one. This is the +discipline [ADR 0048](0048-a-module-broker-account-is-scoped-by-emits-and-consumes.md) applied to the +broker account, applied here to the packet filter: the declaration is the enforcement, or it is a +comment. + +### A public service is exposed through the proxy, not by opening its own port + +Reaching the public internet is normally not `from: anywhere` on the service's own port. The service +listens `from: mesh` — only the proxy reaches it — and `requires: route`, so the sole machine with a +public opening is the one running the reverse proxy, and the service is exposed by name through it. +`from: anywhere` is the deliberate direct-exposure case, for a service that is its own front door. + +## Consequences + +- Issue 003 is closed: the firewall is computed from `listens` and enforced, so a declared scope is + real and an undeclared port is shut. Rejecting unknown manifest keys is the general fix, of which + the `scope:` key was one instance. +- The firewall and the reverse proxy stop being confused for one model: the firewall is the machine's + filter (host-derived from `listens.from`); `route` is a name-router (a provider); the public DNS + name is a third thing ([ADR 0049](0049-a-public-name-is-provisioned-like-any-capability.md)). A + public service uses all three. +- The modelling question is answered: a module registers a port by declaring `listens`, and reaches + the public internet by name through `route` + `public-dns` — never by the firewall being a + provider. + +## References + +- [ADR 0037](0037-the-host-applies-it-does-not-decide.md) — the host applies; the firewall is one of + the things it applies. +- [ADR 0043](0043-a-declaration-is-an-ordered-list-of-owned-resources.md) — the firewall is a derived + owned resource, not a grant. +- [ADR 0048](0048-a-module-broker-account-is-scoped-by-emits-and-consumes.md) — the same discipline: + a declaration is enforced, or it is a comment. +- [ADR 0049](0049-a-public-name-is-provisioned-like-any-capability.md) — the public name, the other + half of the reachability question this was asked with. +- [04-ISSUES/003](../04-ISSUES/003-firewall-scope-is-read-by-no-code/00-report.md) — the unenforced + `scope:` this closes. From 9eb5576682577c88a02e00e30d3acf695a0837be Mon Sep 17 00:00:00 2001 From: jochen Date: Fri, 4 Sep 2026 20:56:44 +0200 Subject: [PATCH 08/10] =?UTF-8?q?04-ISSUES/003=20=E2=80=94=20fixed:=20the?= =?UTF-8?q?=20firewall=20scope=20is=20enforced=20now?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The manifest refuses unknown keys (DisallowUnknownFields), 'from' is the field that scopes a port and it is rendered to nftables (AsNftables), and the firewall module applies the rule set. The chain from a declared scope to a dropped packet is closed. Amended-design: ADR 0050. Claude-Session: https://claude.ai/code/session_01LrgweAeERJYBg88c5cKDzF --- .../00-report.md | 37 ++++++++++++++----- 1 file changed, 27 insertions(+), 10 deletions(-) diff --git a/04-ISSUES/003-firewall-scope-is-read-by-no-code/00-report.md b/04-ISSUES/003-firewall-scope-is-read-by-no-code/00-report.md index f2b5d5c..9115d64 100644 --- a/04-ISSUES/003-firewall-scope-is-read-by-no-code/00-report.md +++ b/04-ISSUES/003-firewall-scope-is-read-by-no-code/00-report.md @@ -1,9 +1,9 @@ --- -status: open +status: fixed opened: 2026-08-22 -located-in: [] -fixed-by: -amended-design: +located-in: [mesh-control/internal/catalogue, mesh-catalog/modules/firewall] +fixed-by: the manifest refuses unknown keys, `from` is the field that scopes a port and it is rendered to nftables, and the firewall module applies it +amended-design: 0050-a-machine-firewall-is-the-sum-of-what-it-listens-on.md --- # 003 — A firewall rule's `scope:` is read by no code @@ -31,10 +31,27 @@ any check. - Five manifests carry the key. Zero code paths consume it. - Recorded as an observation on 2026-08-22. -## Open questions +## Resolution -- Should the manifest reject unknown keys outright? That is the general fix; this is one - instance of it. -- Were the five declarations intended to restrict something that is currently open? Each needs - checking against what the node actually exposes — the declaration cannot be trusted either - way. +Both open questions are answered, and the chain from a declared scope to a packet actually dropped +is closed — recorded as [ADR 0050](../../02-DECISIONS/0050-a-machine-firewall-is-the-sum-of-what-it-listens-on.md). + +- **Unknown keys are refused, not accepted.** `ParseManifest` decodes with + `DisallowUnknownFields`, so a `scope:` key the firewall type does not have is now rejected at the + manifest — the general fix, of which this was one instance. A key that reads as a restriction can + no longer be one nothing enforces. +- **The field that scopes a port is `from`, and it is read.** A `listens` entry names its source — + `mesh`, `anywhere` or `machine` — and the control plane renders the union of every module's + `listens` into a node's whole nftables rule set (`AsNftables`), default-drop with an accept scoped + to exactly the source each port named. The five `scope:` declarations were the wrong spelling of + that intent; `from` is the right one, and it is enforced. +- **A module applies it.** The rendered rule set is written to the node (the `filtering` resource), + and the `firewall` module (mesh-catalog) loads it — the last link, without which the rules were + computed and never dropped a packet. + +## Original open questions + +- Should the manifest reject unknown keys outright? — **Yes; it does now** (`DisallowUnknownFields`). +- Were the five declarations intended to restrict something that is currently open? — They meant to + scope a port and used a key nothing read; expressed through `from`, that intent is now enforced. + Any manifest still carrying `scope:` is refused at parse, so it is found rather than believed. From e898a3ec4405b285281c810e70e26c93554c592f Mon Sep 17 00:00:00 2001 From: jochen Date: Fri, 4 Sep 2026 21:05:55 +0200 Subject: [PATCH 09/10] =?UTF-8?q?ADR=200051=20=E2=80=94=20a=20module's=20c?= =?UTF-8?q?onfiguration=20is=20its=20assignment's,=20not=20its=20manifest?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A module is assigned to a node (there is no mesh assignment; 'mesh' is a scope). The manifest is what the module IS, plus defaults; the configurable values are settings, carried by the assignment — per-node or mesh-wide, applied at resolution, changeable live (what a meshboard edits). Extends settings from a config file's content to the manifest fields marked settable: foremost listens.from (postgres from:mesh by default, from:anywhere per node — the firewall follows), and a provider's own config (a registrar's zone/domain/ ingress). Static config in a manifest is config in the wrong place: it cannot vary per node and cannot change without a rebuild. Claude-Session: https://claude.ai/code/session_01LrgweAeERJYBg88c5cKDzF --- ...ion-is-its-assignments-not-its-manifest.md | 87 +++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 02-DECISIONS/0051-a-module-configuration-is-its-assignments-not-its-manifest.md diff --git a/02-DECISIONS/0051-a-module-configuration-is-its-assignments-not-its-manifest.md b/02-DECISIONS/0051-a-module-configuration-is-its-assignments-not-its-manifest.md new file mode 100644 index 0000000..f0ffa13 --- /dev/null +++ b/02-DECISIONS/0051-a-module-configuration-is-its-assignments-not-its-manifest.md @@ -0,0 +1,87 @@ +--- +status: proposed +date: 2026-09-04 +deciders: jochen +reconstructed: false +extends: 0005-capabilities-are-provisioned-on-declaration.md +--- + +# 51. A module's configuration is its assignment's, not its manifest's + +## Context + +A module is assigned to a node — `assign `, always to a machine; there is no +assignment to the mesh. "Mesh" is a *scope*, not a place: a `provides` or a `claim` scoped `mesh` +reaches the whole mesh, but the module still runs on a node. So the two kinds of thing a module can +carry are the manifest (what the module *is*) and, separately, what it should do *here* — which +differs by deployment and by node. + +The mesh already has the second: **settings**. `settings set [--node ]` — with a node +it is that machine's, without it the whole mesh's — layered over what the module declares and applied +at resolution, changeable without editing the module and without a rebuild. That is the surface a +meshboard would edit. + +But settings today reach only a module's **config-file content** (a mergeable file the module owns). +Configuration that is not a file has been landing in the manifest instead, statically — a registrar's +zone and domain, the address public names point at, and, most sharply, `listens.from`. That last one +is the tell: whether a port is open to the private overlay or to the public internet is a +*per-node deployment choice* — the same database internal on one machine and public on another — and +a value fixed in the manifest is one value for every machine, so it cannot be. Static configuration in +the manifest is configuration in the wrong place: it cannot vary per node, and it cannot change +without a new module version. + +## Decision + +### The manifest is identity and defaults; the assignment's settings are the configuration + +A module's manifest declares what it is — what it provides, requires and claims, the shape of its +resources — and, for anything configurable, a **default**. The values that make a running instance +*this* instance are settings, carried by the assignment: per-node, or mesh-wide when no node is named, +applied over the defaults at resolution. Change one and the next reconcile carries it; nothing is +edited on a machine and nothing is rebuilt. + +### Settings drive the configurable fields the manifest marks, not only file content + +Settings extend beyond a config file's content to the manifest fields a module declares settable — +foremost: + +- **`listens.from`**: a module declares its safe default (`from: mesh`), and a per-node setting + raises or lowers it. postgres declares `listens: [{ port: 5432, from: mesh }]`; on the machine that + should expose it, a setting makes that port `from: anywhere`. Same module, different exposure, and + the firewall ([ADR 0050](0050-a-machine-firewall-is-the-sum-of-what-it-listens-on.md)) is computed + from the effective value, so the packet filter follows the setting. +- **A provider's own configuration**: a registrar's zone, domain and the ingress its names point at + ([ADR 0049](0049-a-public-name-is-provisioned-like-any-capability.md)) are mesh-wide settings, not + manifest constants — one mesh's Cloudflare zone is not another's, and the module description is the + same for both. + +### Unset is the default, and an unknown setting is refused + +A field with no setting keeps the manifest's default, so a module runs correctly configured by nobody. +A setting that matches no settable field — like a config value that reaches no file today — is named, +not silently dropped, so a misspelled setting is found rather than believed (the discipline of +`UnusedSettings`, and of [ADR 0048](0048-a-module-broker-account-is-scoped-by-emits-and-consumes.md): +a declaration is enforced or it is a comment). + +## Consequences + +- The postgres case works: one module, `from: mesh` by default, `from: anywhere` where a setting says + so — internal on ace, public on novox, changeable live. +- Provider modules stop carrying a mesh's specifics: `cloudflare-dns` describes *a Cloudflare + registrar*, and *which* zone and ingress is a setting, so the same module serves every mesh. +- Configuration becomes a thing a meshboard manages — set per node or mesh-wide, applied on the next + reconcile — rather than a manifest edit and a rebuild ([ADR 0004](0004-managed-files-are-generated-never-edited.md): + the way you change a managed thing is not by editing it). +- What a manifest may not do is grow a value that differs per machine; if it differs per machine it is + a setting, and the manifest holds only the default. + +## References + +- [ADR 0005](0005-capabilities-are-provisioned-on-declaration.md) — what is provisioned on + declaration; its per-instance values are the assignment's. +- [ADR 0004](0004-managed-files-are-generated-never-edited.md) — a managed thing is changed through + the mesh, not by editing it; settings are that, for configuration. +- [ADR 0050](0050-a-machine-firewall-is-the-sum-of-what-it-listens-on.md) — the firewall follows the + effective `listens.from`, so making `from` a setting makes exposure a setting. +- [ADR 0049](0049-a-public-name-is-provisioned-like-any-capability.md) — the provider whose zone and + ingress are settings, not manifest constants. From f63eca13b3220bc09af928fe052f09ff5353b720 Mon Sep 17 00:00:00 2001 From: jochen Date: Fri, 4 Sep 2026 21:24:46 +0200 Subject: [PATCH 10/10] =?UTF-8?q?ADR=200052=20=E2=80=94=20a=20module=20run?= =?UTF-8?q?s=20its=20code=20as=20its=20own=20process,=20with=20its=20own?= =?UTF-8?q?=20account?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The runtime-model gap the review found. A module with tools or events runs one container — the tool runtime carrying its code — holding the one scoped account ADR 0048 gave it. A node-wide runtime can't: it would hold the union of every module's permissions, the isolation 0048 draws. So per-module: one module, one process, one account. Tools served per key (serve.) so a caller names a tool and only its module answers (superseding a shared tools.invoke); events in the same process under the same account; the runtime image is the tool runtime plus the module's code (the audit-logger's shape, made the rule). A plain service module runs no such process. A provider's provisioner is a runtime too — which is why a provisioner that emits must carry a broker credential or not emit. Claude-Session: https://claude.ai/code/session_01LrgweAeERJYBg88c5cKDzF --- ...as-its-own-process-with-its-own-account.md | 86 +++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 02-DECISIONS/0052-a-module-runs-its-code-as-its-own-process-with-its-own-account.md diff --git a/02-DECISIONS/0052-a-module-runs-its-code-as-its-own-process-with-its-own-account.md b/02-DECISIONS/0052-a-module-runs-its-code-as-its-own-process-with-its-own-account.md new file mode 100644 index 0000000..86f532b --- /dev/null +++ b/02-DECISIONS/0052-a-module-runs-its-code-as-its-own-process-with-its-own-account.md @@ -0,0 +1,86 @@ +--- +status: proposed +date: 2026-09-04 +deciders: jochen +reconstructed: false +extends: 0048-a-module-broker-account-is-scoped-by-emits-and-consumes.md +--- + +# 52. A module runs its code as its own process, with its own account + +## Context + +A module is one self-contained thing ([ADR 0045](0045-what-a-module-is.md)), and it gets a broker +account scoped to what it emits and consumes ([ADR 0048](0048-a-module-broker-account-is-scoped-by-emits-and-consumes.md)). +The catalogue now gives modules **tools** and **events** — real code, in the module ([ADR 0044](0044-what-the-sdk-holds-and-refuses.md)) — +but nothing has said what *runs* that code. The audit-logger showed one shape and was treated as an +exception: a container running the tool runtime carrying the module's compiled code, holding the +module's own scoped account. Every module with tools or events needs the same, and the tempting +alternative does not work. + +**A node-wide runtime that loaded every assigned module's code cannot hold a per-module account.** It +would run under one account with the union of every module's permissions — able to emit as any of +them and read any of their queues — which is exactly the isolation ADR 0048 exists to draw. So the +runtime is per-module, not per-node, and treating the audit-logger as special left the other +modules' code with nothing to run it: the conversion produced tools and events that, as it stands, +never execute. + +## Decision + +### A module with tools or events runs a process of its own + +A module that has tools or events runs a **runtime process** — a container, the tool runtime carrying +that module's compiled code — assigned and started like the module it is, holding the single broker +account the mesh scoped to it (ADR 0048). One module, one process, one account. + +### It serves its tools, each on its own key + +A tool is served on its own key (`serve.`), and a caller invokes a named tool. Only the module +that serves it answers, and the module's account is scoped to exactly its tool keys — so one module +cannot answer another's calls, the isolation ADR 0048 gives events extended to tools. This supersedes +a single `tools.invoke` endpoint that dispatched by name: that shape assumed one runtime for the +whole node, and per-module runtimes competing on one key would each be handed calls for tools they do +not have. + +### It runs its events in the same process, under the same account + +Emitting under the module's own origin and consuming its own queue ([ADR 0047](0047-the-shape-of-an-event-on-the-wire.md)) +happen in that same process, with that same account — not a second one to scope and seal. A module's +tool code, its event code and, for a provider, its provisioner are the one module's code and run as +the one module's process. + +### The runtime image is the tool runtime plus the module's code + +Built from the module's source like any module image — the audit-logger's shape, made the rule, not +the exception. The module declares a `container` for it carrying `MESH_BROKER_FILE` (its sealed +credential, ADR 0048) and its compiled code. A module with **neither** tools nor events runs no such +process: a plain service module — the plex *server*, dnsmasq the resolver — is its service and files +and nothing more. A module that is both a service and code declares both containers: the service, and +the runtime beside it. + +## Consequences + +- The catalogue's tools and events become runnable: each tools-or-events module gains a runtime + container with its scoped credential, and the audit-logger stops being special. Until this, the + converted modules held code with nothing to execute it. +- A process, and a small image, per tools-or-events module. That is the cost of ADR 0048's isolation: + one account per module means one process per module. It is paid deliberately — a shared runtime is + cheaper and cannot be scoped, and a mesh where any module can emit as any other is not one worth the + saving. +- `serve.` per key replaces the single `tools.invoke` dispatch. The sdk's serving and a module's + account scope both come to name tools individually. +- **A provider's provisioner is a runtime process too.** It already runs as its own container; its + events (`bucket.created`, `database.provisioned`) belong to *that* process and need the same + credential. So a provisioner that emits carries `MESH_BROKER_FILE` and its scoped account like any + runtime — or it does not emit. (This is the fix for provisioners that emit today with no broker + bound: the emit is a runtime's, and the provisioner is a runtime.) + +## References + +- [ADR 0045](0045-what-a-module-is.md) — a module is one self-contained thing; its code runs as one + process. +- [ADR 0048](0048-a-module-broker-account-is-scoped-by-emits-and-consumes.md) — the scoped account + this process holds, and the isolation that makes it per-module. +- [ADR 0047](0047-the-shape-of-an-event-on-the-wire.md) — the events this process runs, and the + `serve.` queue tools now use. +- [ADR 0044](0044-what-the-sdk-holds-and-refuses.md) — the code lives in the module; this runs it.