Enforcement-Point Lint Dispositions — ADR-089, ADR-090
Enforcement-Point Lint Dispositions — ADR-089, ADR-090
Date: 2026-08-02 · Disposes of: the two remaining findings of
tools/enforcement_point_lint.py— the only normative sentences in the whitepaper that named no enforcement mechanism anywhere in the document. Not an audit round. These did not come from a Phase review; they came from ADR-045’s own lint, implemented 2026-08-01. This file is named for that provenance rather than given anR-number that would imply a sixth review. Decision made by the project owner: both sentences remainMUST. Neither is demoted to description. ADR-045 offered two paths and the stricter one was taken.
What the lint had found
enforcement_point_lint.py flagged six uppercase MUST sentences with no enforcement point in their paragraph or table. Four were cross-reference gaps — the mechanism existed elsewhere in the document and the sentence simply did not name it — and were closed by naming it inline (§2.1.1, §2.2, §3.1, §7.3). These two were different: no mechanism existed anywhere. They are the outcome ADR-045 predicted when it wrote that the enforcement-point rule “would force the deletion or demotion of some existing MUST sentences” — except that here the ruling supplies mechanisms rather than demoting the claims.
ADR-089 — The Renderer Read-Only Boundary Is Enforced by Type and by Two Import Graphs
Status: Accepted · Date: 2026-08-02 · Amends: 007 · interacts with 053
Context
§5.1 states, in a blockquote of its own: “Renderers MUST NOT mutate Domain World State directly.” It is one of the oldest and most structural claims in the document, and until now nothing enforced it. §14.4 enumerates five frontend build-failing boundary assertions and this was not among them, while recording that frontend boundaries are otherwise “guarded by review alone” — so the claim rested on the one thing §14.4 itself says does not hold for a decade.
Decision
1. The read surface is a type, not a convention. Renderers receive IReadOnlyWorldState or immutable snapshot structures. There is no mutating member reachable from the type a renderer is handed, so the prohibited operation is not available to be called rather than being available and forbidden. This is the same move ADR-042 made for scope confusion and ADR-082 made for entitlement: make the wrong thing unexpressible instead of prohibited.
2. The .NET side is asserted by NetArchTest in Descent.ArchitectureTests: no type reachable from a presentation or renderer-facing surface may depend on a domain write or update method, and the build fails if one does.
3. The frontend side is asserted in §14.4’s existing dependency-cruiser list, and this clause is why the ruling is two-sided. The renderers this sentence names — Babylon.js and PixiJS — are frontend TypeScript, so a .NET architecture test cannot reach them. Clause 2 alone would produce a green build that constrains the wrong codebase, which is the “a rule satisfiable by writing a sentence” failure this corpus has recorded twice. The added assertion: no renderer module may import the command or mutation surface; mutations reach the server only through the CQRS Command Queue, which is ADR-007’s Intent Command pattern stated as an import-graph property a machine can check.
4. Nothing about the runtime authority model changes. The server was always authoritative and a renderer “mutating” state was always local-only. This ADR is about making a claimed boundary checkable, not about closing a live exploit.
Alternatives Considered and Why Rejected
- Demote the sentence to description. ADR-045’s other path, and rejected by the project owner. It is a defensible choice for an unenforceable claim and this claim turned out to be cheaply enforceable.
NetArchTestalone, as first proposed. It cannot reach the renderers, which are frontend. Adopting it alone would satisfy the lint while leaving the actual boundary unguarded — worse than the original gap, because the ledger would show it closed.- Runtime guard on the write path. Detection rather than prevention, and it would fire in production for a defect that should fail a build.
Consequences (including the negative ones)
- Two enforcement points must be kept in step across two repositories. The frontend is a separate repository (§3), so this rule’s two halves cannot be verified by one CI run — a real weakness, and the reason clause 3 puts the frontend half into §14.4’s existing enumerated list rather than inventing a new home for it.
IReadOnlyWorldStatemust not become a lie. A read-only interface over a mutable object still permits mutation via casting; the assertion in clause 2 is what stops that, so the type and the test are one mechanism and neither is sufficient alone.
Enforcement
- ArchitectureTests (
NetArchTest): no renderer-facing or presentation type depends on a domain write/update method. Build fails. - CI (dependency-cruiser, §14.4): no renderer module imports the command or mutation surface. Build fails.
- Compile-time: renderers are handed
IReadOnlyWorldStateor immutable structures.
§11 Index Line
ADR-089: The §5.1 renderer read-only boundary is enforced by interface segregation — renderers receive IReadOnlyWorldState or immutable structures — plus NetArchTest on the .NET side and a dependency-cruiser assertion in §14.4's list on the frontend side. The rule is two-sided because the renderers it names are frontend TypeScript, which a .NET architecture test cannot reach.
ADR-090 — The Backplane Prohibition Is Enforced Against the Broadcast API, Not the Payload Type
Status: Accepted · Date: 2026-08-02 · Amends: 032 · interacts with 029
Context
§8.3 states: “snapshots MUST NOT traverse the generic SignalR backplane.” ADR-032’s arithmetic is the reason — a Redis/RESP backplane publishes every group message to every silo, so 500 rooms at 20Hz would force roughly ten thousand publishes per second to be received and deserialised by every silo. Nothing enforced it. §10.1.1’s release-gating validation covers silo-to-silo reachability, which is a different claim.
The first proposed mechanism does not bind, and the reason is worth recording because it is easy to repeat. It was to scan SignalR Hubs by reflection and fail the build on any Hub accepting or returning a Snapshot payload type. But SignalR is the client transport (ADR-029), and ADR-032’s own mechanism ends with “the local dispatcher writes to its own hub connections” — a Hub carrying snapshots to clients is the correct implementation, not the violation. That test would fail the build on correct code, and if loosened to avoid doing so would catch nothing.
The second proposal, a strict SignalR message size limit forcing large payloads to HTTP/REST, is useful and does not enforce this rule either: size and routing are independent. A 5 KB snapshot published through Clients.Group(...) fans out to every silo exactly as a large one would.
Decision
1. The prohibition is enforced against the API call, not the payload type. The violation is invoking a backplane-routed broadcast — Clients.All, Clients.Group(...), Clients.Groups(...), or the IHubContext equivalents — from the snapshot dispatch path. Targeted per-silo forwarding uses per-connection sends on the silo that owns the connection, which never reaches the backplane. This keeps the intended shape of the check — reflection over the Hub surface, build fails — and retargets it at the thing that is actually wrong.
2. A runtime SLO catches what the build cannot. Backplane publish rate must not scale with tick rate × room count; an alert fires when it does. The test constrains code committed to this repository, and the metric constrains what is actually running — including a route through the backplane introduced by a library upgrade or a configuration change that no static check would see.
3. A SignalR maximum message size is adopted as defence in depth and is registered as Q-056, pending. It bounds blast radius rather than routing, and it is deliberately not given a number here: a per-viewer Full Snapshot’s size is unmeasured — it is BENCH-03 territory — and a cap set below it would break the delivery path this ADR exists to protect. A bare 32 KB in this document would also violate ADR-045 clause 3. The value is set when the snapshot size distribution is known.
4. The backplane keeps its intended job, unchanged from ADR-032: low-frequency cross-room concerns — presence, notifications, admin broadcasts — which is what it is good at.
Alternatives Considered and Why Rejected
- Scan Hubs for
Snapshotpayload types. The first proposal. Fails the build on the correct implementation, for the reason in Context. - A message size limit as the primary control. Does not constrain routing at all; retained as clause 3’s secondary measure.
- Demote the sentence to description. Rejected by the project owner, and ADR-032’s arithmetic makes the claim worth keeping: this is a capacity cliff, not a preference.
- Forbid
IHubContextgroup APIs process-wide. Over-broad — presence and admin broadcasts legitimately use them. The constraint belongs to the snapshot dispatch path.
Consequences (including the negative ones)
- The test must know what “the snapshot dispatch path” is, which is a naming or attribute convention rather than a language-level boundary. It is weaker than ADR-089’s type-level guarantee, and the runtime SLO in clause 2 exists because of that weakness rather than as belt-and-braces.
Q-056is another pending value on a list this corpus already flags as long. It is pending for a stated reason with a named unblocking measurement, which is the distinction the registry exists to preserve.
Enforcement
- CI (reflection over the Hub surface): no backplane-routed broadcast API is invoked from the snapshot dispatch path. Build fails.
- Runtime metric / SLO: backplane publish rate does not scale with tick rate × room count; alert on breach.
- Infrastructure: SignalR maximum message size set to
Q-056once measured.
§11 Index Line
ADR-090: The §8.3 backplane prohibition is enforced against the broadcast API rather than the payload type — a Hub carrying snapshots to clients is the correct implementation, so a payload-type scan would fail the build on correct code. CI asserts no backplane-routed broadcast API is called from the snapshot dispatch path; a runtime SLO asserts backplane publish rate does not scale with tick rate × room count; a SignalR maximum message size is registered as Q-056 and stays pending because a cap below the unmeasured Full Snapshot size would break delivery.