Skip to content

The Four Owner Decisions — ADR-104…107

The Four Owner Decisions — ADR-104…107

Date: 2026-08-06 · Written in English per the 2026-08-01 convention change.

Provenance. These four were recorded across Phases 3–5 and in docs/README.md as “blocked on decisions only you can make”. The project owner delegated them on 2026-08-06 with the instruction that the outcome be the most appropriate available and reflect 2026+ practice.

How the delegation is executed, because it changes what these records are worth. Two of the four have a genuinely commercial half — who the customers are, and what a table’s social contract is. I can rule architecture and I cannot invent business facts, so where the commercial half is load-bearing the ruling does three things instead of guessing: it dissolves the dependency where the blocker turns out to be a design flaw rather than a missing fact, it states a default and labels it a default, and it names the evidence that would overturn it. A number chosen here that pretends to be a market judgement would be exactly the invisible compromise ADR-087 refused to make.

This file continues the R-numbered sequence for filing purposes only.


ADR-104 — Room Membership Is a Set of Revocable Grants; the Policy Is Configuration With Fail-Safe Defaults

Status: Accepted · Date: 2026-08-06 · Amends 085 · Depends-on 026 (the document-write capability token is one of the two paths revocation must reach) · Depends-on 022 (export entitlement is the other) · interacts with 069, 078, 080

Context

ADR-085 ruled the architectural half and stopped there, deliberately: “This document does not define who may remove whom, whether removal is reversible, or what a removed participant retains — those are product decisions and inventing them here would read as decisions somebody made.” What it did rule is the part that outlives a session: departure and erasure are two verbs and must never become one operation, and revocation must reach the ADR-026 document-write capability token and export entitlement (ADR-022), because everything else re-derives authority per request from the RoomGrain.

Phase 5 additionally found that invite, eject, ban and revoke have zero occurrences in the specification, and all nine membership hits are Orleans clustering. There is no model to configure.

Decision

1. Membership is a set of grants, and a grant is a domain event on the room’s own stream. ParticipantGranted { SubjectId, Role, GrantedBy, At } and ParticipantRevoked { SubjectId, RevokedBy, At }. This follows ADR-069’s shape — an adjudicated outcome with its inputs attached as evidence, never an instruction — so a projection rebuild replays membership and never re-adjudicates it.

2. Revocation is not deletion, and re-granting is the undo. A revoked participant’s grant is superseded, not erased; the stream retains both events. Removal is therefore reversible by construction, which is the answer to ADR-085’s second question and is not a policy choice — it falls out of the event store. Making removal irreversible would require deleting from a log that never deletes, which ADR-078 forbids by construction.

3. Revocation propagates synchronously to the two paths ADR-085 named, and to nothing else, because nothing else needs it: the document-write capability token is invalidated by RoomId + SubjectId, and export entitlement is re-checked against live membership at job start rather than at request time. A capability token outliving the authority that issued it is the only failure mode here, and it has exactly two instances.

4. What a removed participant retains — and this is ADR-085’s third question answered by its own first rule. Their authored content remains (departure is not erasure). Their read access ends at the room boundary. Their export entitlement ends, including for exports requested before removal but not yet delivered. Erasure remains a separate request on the separate path ADR-080 defines.

5. The policy — who may remove whom — is configuration with a fail-safe default, not a constant. Default: the Keeper may revoke any participant; any participant may revoke themselves; nobody else may revoke anyone. A deployment may narrow this and may not widen it, which is the fail-safe direction and is the same shape §5.3’s ladder uses.

The default is labelled a default. It matches how tables actually work — the person who runs the game manages the table — and it is the smallest policy that makes the mechanism usable. It is not a claim about what the product should offer. The evidence that would overturn it is a supported co-Keeper role or an out-of-band moderation requirement, and either is a narrowing or widening of clause 5 rather than a change to clauses 1–4.

Alternatives Considered and Why Rejected

  • Adopt an external authorization service — AuthZEN, OpenFGA or equivalent. This is the 2026 answer for the general problem, and AuthZEN reached Final specification status in January 2026, so it is a real and current option rather than a hypothetical. It is rejected for the room path on three independent grounds, and each is sufficient: a PDP call is a network hop, and §5.2 budgets the tick in milliseconds; an external policy store would be a second source of truth about who may see what, which §9.2 forbids outright because visibility is a server-owned security property; and ADR-002 rejects microservices, so introducing a mandatory external service for the hottest authority decision in the system inverts the whole deployment argument. It is not rejected for the Marketplace, whose HTTP surfaces are exactly the request/ response shape AuthZEN is designed for, and whose momentum in 2026 is in gateways. That is a Marketplace decision under ADR-M, and this record does not make it. What is adopted is the model, not the interface: membership expressed as relationships with explicit revocation is ReBAC’s contribution, and clauses 1–3 are that shape, evaluated in-process against RoomGrain state.
  • Tombstone the participant instead of superseding the grant. Rejected: a tombstone on the person is one step from a tombstone that means erasure, which is precisely the conflation ADR-085 exists to prevent. Superseding a grant keeps the two verbs apart in the data model rather than only in the prose.
  • Re-check export entitlement at request time. Rejected: an export is an offline job that may start minutes later, so request-time is the wrong instant. Job-start is the last moment before content leaves.
  • Leave the policy undefined and let the first implementer choose. Rejected. That is the status quo, and the status quo is why eject and revoke appear nowhere: an undefined policy is not neutral, it is a policy chosen silently by whoever writes the first UI.

Consequences (including negative)

  • A revoked participant’s identity remains in the room’s event stream, which is correct under ADR-078 (opaque identifiers; the linkage is the erasable record) and would be wrong under any design that stored names in events. This ADR depends on ADR-078 already being true.
  • Re-granting restores read access to content authored while the participant was absent. That is the honest consequence of reversibility, and a deployment that finds it unacceptable is asking for a different clause 5, not a different clause 2.
  • Clause 3 makes revocation a synchronous cross-component operation on a path that is otherwise per-request. It is rare, so the cost is acceptable; it is also the one operation here that can partially fail, and a failed token invalidation must retry rather than log.

Rights-holders (ADR-079)

The revoked participant, and the claim is already honoured: their authored content remains theirs under clause 4, and erasure remains available on ADR-080’s separate path. No new class of retained data is introduced — membership events carry a SubjectId, which ADR-078 already governs.

Enforcement

  • Membership is replayed by Room.Apply, so a room reconstructs its participant set from its own stream — the same structural property ADR-095 uses for vocabularies, and a test asserts it.
  • Descent.ArchitectureTests asserts the two revocation paths are reachable from ParticipantRevoked handling, because clause 3’s failure is silent: a stale capability token produces no error, which is why ADR-085 named those two paths in the first place.
  • A test asserts export entitlement is evaluated at job start, not at enqueue.

ADR-105 — A Quota Is Two Bounds With Different Jobs, and the Entitlement Tier Scales Only One

Status: Accepted · Date: 2026-08-06 · Amends 087 · interacts with 015, 047, 059 · governs Q-034, Q-049

Context

ADR-087 left Q-034 and Q-049 pending, and its reasoning is the thing to preserve: “a quota bounding legitimate high volume and abuse with one number encodes a compromise in a constant nobody will later recognise as one.” It named the blocker as the product’s entitlement-tier model rather than measurement, and declined to pick.

The objection was correct and the conclusion was one step short. ADR-087 treated “one number cannot do two jobs” as a reason to choose no number. It is equally a reason to have two numbers. Once they are separate, neither encodes a compromise, and only one of them needs the tier model.

Decision

1. Every per-account bound in §10.3 is two bounds.

Abuse boundCost bound
Protectsother tenants, and the disclosure surfacethe bill
Derived fromthe shape of legitimate behaviourmeasurement, then commercial choice
Scales with entitlement tierneveryes
Blocked on the tier modelnoyes

2. The abuse bound is decidable now, and is decided now, because it is a statement about what a human operating a UI can coherently do — not about what a customer has paid for:

  • Q-049 CHECKOUT_CONCURRENCY_PER_ACCOUNT = 2. A timeline checkout is a Keeper-driven operation with a preview to read (ADR-075 builds the candidate outside the refusal window precisely so a human can look at it). Two allows the genuine case — one person running two tables — and makes the fifty-room scripted traversal §10.3 face 6 describes infeasible. Q-013 already bounds the per-room rate; this is the cross-room bound it cannot see.
  • Q-034 EXPORT_CONCURRENCY_PER_ACCOUNT = 1. An export is an offline job delivered by a short-lived link. There is no user-visible benefit to two at once — serialising costs the requester wall-clock and nothing else — and concurrency is the term that turns a per-account daily allowance into a burst against the shared Jobs pool.

3. Q-034’s daily allowance stays pending, and its absence no longer leaves anything unprotected. With concurrency bounded at 1, the burn rate is bounded by the offline pool’s own capacity regardless of the daily figure. The daily cap therefore stops being a protection and becomes purely commercial — which is exactly where ADR-087 wanted the tier model to decide it, and now it can do so without a gap standing open in the meantime.

4. Both bounds are labelled in Quantity_Registry.md with the job they do. ADR-087’s closing requirement — “a bound doing one job must not be documented as doing two” — is satisfied by the bound doing one job.

Alternatives Considered and Why Rejected

  • Keep both pending until the tier model exists. Rejected: it was the right call when a single number was the only option, and it stops being right once the compromise can be factored out. It also leaves Q-049 — a bound whose absence is the amplification path §10.3 face 6 names — open for a commercial reason it does not depend on.
  • Pick a single generous number now and tighten later. Rejected: this is precisely the invisible compromise, and tightening a published quota is a customer-visible regression, so “later” does not arrive.
  • Derive the abuse bound from telemetry once there are users. Rejected as circular for Q-049: the behaviour it bounds is one that has never been possible, so telemetry cannot observe it. A bound on a shape is decidable before the shape occurs; a bound on a volume is not.
  • Make the abuse bound tier-scalable “for enterprise customers”. Rejected explicitly, because it is the request that will be made. An abuse bound that money can raise is a cost bound wearing the wrong label, and the tenant it protects is not the one paying.

Consequences (including negative)

  • A legitimate high-volume Keeper hits Q-049 = 2 and experiences it as a limit. That is real, and it is the correct failure: the refusal names the bound, and raising it is a deliberate change to a bound that protects other tenants — not a plan upgrade.
  • Two bounds are more to document and to reason about than one, permanently.
  • This does not answer whether a high-volume GM is a supported customer. It removes that question from the critical path, which is a smaller claim and the honest one.

Rights-holders (ADR-079)

None. No new class of data is retained.

Enforcement

  • Both bounds are registry entries with an explicit job, and the cost bound’s Pending status now blocks only a commercial decision rather than a protection.
  • §10.3’s release-checklist item — every face names a metric that rises under attack — applies to the abuse bound, which is the one that can be attacked.

ADR-106 — Cite Where the Number Is a Claim; Exempt Where It Is Working

Status: Accepted · Date: 2026-08-06 · Amends 045 · governs bare_number_lint.py

Context

ADR-045 clause 3 forbids bare numbers outright and requires every quantity to cite a Q-ID. The whitepaper body has never complied: 129 unit-carrying quantities, 26 cited, 103 uncited as of 2026-08-06 — improved from 113 uncited and 5 distinct IDs on 2026-08-01, an improvement nobody recorded until this week.

About a third of the uncited sit inside §5.2’s Table A and Table B, whose entire purpose is to show their own arithmetic (0.25 ms + 0.40 ms + 2.00 ms + … = ⊙3.04 ms). The rule as written forbids a derivation from displaying its working, which is why bare_number_lint.py has run in measurement-only mode since it was built: gating on it today would mean a permanently red build, which is how a check stops being run at all.

Decision

  1. A quantity that makes a claim must cite a Q-ID. That is every Normative, and Invariant value, wherever it appears.
  2. A quantity that is an intermediate in a displayed derivation is exempt, provided the derivation’s result cites one. The working is not a claim; the total is.
  3. The exemption is declared, not inferred. A derivation block is marked as such, so the lint knows what it is looking at and a reader can tell an exempt intermediate from an uncited claim. An exemption the tool guesses at is an exemption that grows.
  4. bare_number_lint.py becomes a gate once the body satisfies clauses 1–3 — --strict in docs-lint.yml. Until then it stays measurement-only with the remaining count published, and the count may not rise.

Alternatives Considered and Why Rejected

  • Rewrite Table A/B to cite IDs for every intermediate. Rejected: it destroys the artefact. A derivation whose terms are Q-0xx + Q-0yy + Q-0zz cannot be checked by a reader, and those tables exist because §5.2’s budget was previously asserted rather than shown.
  • Leave the lint in measurement mode permanently. Rejected: ADR-045 clause 3 would then be a rule with no enforcement point, which is the exact thing ADR-045 itself forbids. A governance rule that exempts its own enforcement is the strongest possible argument against the rest of the document’s rules.
  • Require a citation only in normative sentences, ignoring tables. Rejected as under-inclusive in the dangerous direction: Q-009’s 64 MiB and Q-012’s 256 both live in tables and both are claims.
  • Auto-generate the citations. Rejected: the tool cannot tell which registry entry a bare 50ms refers to, and a wrong citation is worse than none — it looks checked.

Consequences (including negative)

  • Marking the derivation blocks is manual work across §5.2 and anywhere else arithmetic is displayed, and it must happen before the gate can be turned on.
  • Clause 3 adds syntax to the corpus, which is a cost paid by every future author of a table.
  • The gate is not turned on by this ADR, and saying so is the point: this records the rule and the remaining work, rather than claiming the problem is solved.

Rights-holders (ADR-079)

None.

Enforcement

  • bare_number_lint.py gains derivation-block awareness and the --strict flag becomes meaningful; docs-lint.yml publishes the remaining count until it is zero.
  • The count may not rise, which is the ratchet enforcement_baseline.txt already applies to lint #3 and is what stops this becoming a debt that grows while nominally being managed.

ADR-107 — A Change to a Vendored Module Is Offered Upstream or Declared a Fork

Status: Superseded-by 111 (2026-08-08 — the engines are proprietary and owned outright, so the upstream relationship this rule polices no longer exists; the diagnosis is carried forward in ADR-111’s record) · Date: 2026-08-06 · interacts with 100

Context

Descent.Geometry, Descent.RngKit and Descent.Sandbox are MIT modules vendored into this repository, with upstream repositories that still exist and remain the origin of their history. docs/backend/README.md states that contributions “belong upstream”, and this repository has now made two substantial changes to Descent.Sandbox that have not been offered there — ADR-100’s API change and the test-suite restructuring around it.

The failure mode is not the fork. It is the fork nobody declared, which is indistinguishable from an intention to contribute that never happened.

One premise checked and found false. The corpus, the root README.md and two of the .claude-rules files state that RngKit’s Native AOT publish gate and both modules’ release workflows are not covered by root CI. rngkit-ci.yml runs native-aot and trim-analysis, and both release workflows exist with tag triggers. That claim was true at the convergence and was fixed by the workflow-restoration commit; the documents never caught up. It is corrected alongside this ADR, and it removes what looked like the larger half of this decision.

Decision

  1. A change to a vendored module is offered upstream, or it is a declared fork with a named owner and a recorded reason. There is no third state, and “we will get to it” is the third state.
  2. The declaration is a row in the root README.md’s vendoring table, beside the revision — because that is where a reader already looks to find out what these modules are.
  3. One release is the window. A change that has neither been offered nor declared by the next release of the consuming host is a defect, on the same reasoning Q-015b and the enforcement baseline use: an untracked debt is one nobody can see shrinking.
  4. ADR-100’s changes are declared a fork now, pending an upstream offer. The reason is recorded: they are a breaking API change whose justification is this platform’s §3.1, and an upstream maintainer may reasonably decline it — which is a legitimate outcome and not a failure.

Alternatives Considered and Why Rejected

  • Un-vendor and go back to submodules so upstream is the only path. Rejected: the convergence’s two stated reasons still hold, and one of them — a module change and its consumer being one reviewable commit — is exactly what made ADR-100 tractable.
  • Treat the vendored copies as owned outright. Rejected: the modules are published MIT packages with their own versioning, CHANGELOGs and release workflows. Silently forking a package other people may consume is the outcome this rule exists to make visible.
  • Require upstream-first for every change. Rejected as impractical and as the rule most likely to be ignored: a fix discovered while integrating cannot always wait for an upstream review cycle, and a rule that forbids the normal case is a rule that gets bypassed.

Consequences (including negative)

  • A declared fork is a maintenance cost that is now visible rather than absorbed, which is the point and is also unwelcome.
  • Upstream may decline ADR-100, leaving this repository permanently divergent on a security posture. That is an acceptable outcome, stated so it is not discovered as a surprise.

Rights-holders (ADR-079)

None.

Enforcement

  • The vendoring table in the root README.md carries a Local changes column; a module whose vendored tree differs from its recorded upstream revision with no row is the defect clause 1 names. This is a release-checklist item, not a CI check — comparing a vendored tree against an upstream revision needs network access CI deliberately does not have on this path, and claiming a check that does not exist is worse than naming a human one.

§11 Index Lines

ADR-104: Room membership is a set of revocable grants recorded as domain events on the room's own stream, so removal is reversible by construction rather than by policy and a projection rebuild replays it without re-adjudicating; revocation propagates synchronously to exactly the two paths ADR-085 named — the ADR-026 document-write capability token and ADR-022's export entitlement, re-checked at job start rather than at request time — because everything else re-derives authority per request; a removed participant keeps their authored content, loses read access at the room boundary and loses export entitlement; who may revoke whom is configuration with a fail-safe default that a deployment may narrow and may not widen. An external PDP (AuthZEN, Final January 2026) was rejected for the room path on three independent grounds — a network hop inside the tick budget, a second source of truth about visibility, and ADR-002's rejection of microservices — while the ReBAC model it carries is adopted and evaluated in-process. (Amends ADR-085; Depends-on ADR-026, ADR-022.)

ADR-105: Every per-account bound is two bounds with different jobs — an abuse bound protecting other tenants, derived from the shape of legitimate behaviour and never scaled by entitlement tier, and a cost bound protecting the bill, which is where the tier belongs; ADR-087's objection that one number cannot bound legitimate volume and abuse together was correct and one step short, because it is equally a reason to have two. Q-049 is decided at 2 concurrent checkouts per account and Q-034 at 1 concurrent export, both decidable now because they describe what a human driving a UI can coherently do; Q-034's daily allowance stays pending and its absence no longer leaves anything unprotected, since bounded concurrency caps the burn rate regardless. (Amends ADR-087; governs Q-034, Q-049.)

ADR-106: ADR-045 clause 3 is narrowed so that a quantity making a claim — Normative, ⊙ or Invariant — must cite a Q-ID, while an intermediate in a displayed derivation is exempt provided the derivation's result cites one, because the working is not a claim and the rule as written forbade a derivation from showing it; the exemption is declared rather than inferred, since an exemption a tool guesses at is one that grows; bare_number_lint becomes a gate once the body complies, and until then stays measurement-only with the count published and forbidden to rise. (Amends ADR-045.)

ADR-107: A change to a vendored MIT module is offered upstream or declared a fork with a named owner and a recorded reason, in the root README's vendoring table, within one release — there is no third state, and "we will get to it" is the third state; ADR-100's sandbox changes are declared a fork now, and an upstream maintainer declining a breaking change justified by this platform's §3.1 is a legitimate outcome rather than a failure. Enforced as a release-checklist item rather than a CI check, because comparing a vendored tree against an upstream revision needs network access this path does not have. (Interacts with ADR-100.)