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.mdas “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 againstRoomGrainstate. - 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
ejectandrevokeappear 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.ArchitectureTestsasserts the two revocation paths are reachable fromParticipantRevokedhandling, 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 bound | Cost bound | |
|---|---|---|
| Protects | other tenants, and the disclosure surface | the bill |
| Derived from | the shape of legitimate behaviour | measurement, then commercial choice |
| Scales with entitlement tier | never | yes |
| Blocked on the tier model | no | yes |
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-049CHECKOUT_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-013already bounds the per-room rate; this is the cross-room bound it cannot see.Q-034EXPORT_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
Pendingstatus 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
- A quantity that makes a claim must cite a
Q-ID. That is every Normative, ⊙ and Invariant value, wherever it appears. - 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.
- 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.
bare_number_lint.pybecomes a gate once the body satisfies clauses 1–3 —--strictindocs-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-0zzcannot 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 andQ-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
50msrefers 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.pygains derivation-block awareness and the--strictflag becomes meaningful;docs-lint.ymlpublishes the remaining count until it is zero.- The count may not rise, which is the ratchet
enforcement_baseline.txtalready 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
- 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.
- 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. - 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-015band the enforcement baseline use: an untracked debt is one nobody can see shrinking. - 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.mdcarries aLocal changescolumn; 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.)