ADR 0048 — a module's broker account is scoped by its emits and consumes
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.<self>.* 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
This commit is contained in:
@@ -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
|
||||||
|
`<node>.<module>.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.<name>.*`. 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.
|
||||||
Reference in New Issue
Block a user