Files
hq/03-DESIGN/01-to-be/29-what-a-module-declares.md
T
jschoubben 7b4916e9ec Modules declare their own seats; the mesh reserves mesh-*
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.
2026-09-26 20:34:32 +02:00

12 KiB
Raw Blame History

layer, status, code, updated, decisions
layer status code updated decisions
to-be proposed
2026-09-26
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 is the bus itself — subjects, streams, accounts, enrolment — and stays the authority on the wire. Design 19 is the specification an SDK implements, and is rewritten onto this in step 3 of ADR 0116.

1. A module names locally; the mesh derives the subject

This is the load-bearing rule. ADR 0112 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.<module>.order.placed
consumes: billing.order.placed durable consumer on mesh.mod.billing.order.placed
serves: status queue-group subscription on mesh.mod.<module>.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 gave the sdk, applied to declarations.

2. Three namespaces, and nothing else

Its own — mesh.mod.<module>.>. 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.<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 §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 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.

5. Seats

A module declares a seat with its protocol, and the mesh enforces one holder at its scope (ADR 0118). 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), 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).

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 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).

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.