docs: define the D106 store-owned mutation seal - #367
Merged
Conversation
ADR 0046 D106 requires that a resource commit be reachable only through
evidence a successful authorization evaluation produced, bound to one
store instance. W5fu1 claimed to close that with a public
VerifiedMutationView trait in d2b-resource-store-redb plus a runtime
std::any::type_name comparison at store open. Both were measured
forgeable rather than argued about.
A hostile downstream crate declaring `[lib] name = "d2b_resource_api"`
with `mod admission { pub struct VerifiedMutation; }` matches the
type_name string exactly, implements the unsealed trait, and commits
attacker-chosen authorization. Separately, the guard is
`#[cfg(not(test))]`, so the repository's own external-seals rustc shim,
which forces `--cfg test` onto a dependency precisely to defeat
test-only escape hatches, erases it and accepts any type.
ADR 0049 moves the capability down into d2b-resource-store, the crate
both sides already depend on, as a private concrete SealedMutation with
no public constructor, no Clone, and no Copy, minted only by a
MutationSealIssuer paired with the acceptor one store holds. The
dependency direction and D106's "storage never depends on the API"
invariant are both preserved, and no cycle is created. commit_verified
becomes non-generic and takes the evidence by value, so single use,
exact store binding, exact mutation binding, and cross-store rejection
are compiler-enforced rather than compared at runtime.
Records the rejected alternatives, including the sealed trait that the
dependency direction makes impossible, the inversion that trades a
policy invariant for a type invariant, backend-owned issuers, doc-hidden
constructors, runtime token identifiers, and a nonce ledger that move
semantics already make redundant. Carries the seven measured
compile-fail diagnostics, the capability-root additions, and a two-wave
implementation handoff with mechanically checkable stopping conditions.
Independent verification of ADR 0049 against committed code at dc14502. The core decision survives: a private concrete SealedMutation in d2b-resource-store, minted by an Arc-paired issuer the evaluator retains and consumed by value at a non-generic commit_verified, is the right shape and the dependency direction is measured sound. Twelve claims around it were not. Wave A could not have gone green. tests/golden/api-surface/capability-api.txt carries fourteen lines naming d2b_resource_api::VerifiedMutation and PreparedStoreMutation, and approved-capability-api.txt four more, so deleting the first type and re-homing the second changes census snapshots that the record assigned to Wave B while asking Wave A to exit 0 on make test-rust, which runs the api-surface gate. Wave A now owns the re-pin for the surface it removes; Wave B owns the new roots. Invariant 2 counted the wrong symbol. MutationSealIssuer::seal is public, takes a public-field body, and is retained for the authorizer's lifetime, so bounding mutation_seal_pair call sites leaves the mint unbounded; the committed analogue in d106_policy.rs counts admission.record_allow( for exactly that reason. Both counts now land in d106_policy.rs, which already owns this class of assertion, rather than in a new file that would have used include_str! over a closed list that cannot see a call site nobody remembered to include. The residual was understated. The seal binds evidence to a store instance; it cannot bind a store instance to the evaluator, because mutation_seal_pair and open_owned are both public and must be. A crate outside this workspace can still pair, open its own store, and commit, now without the library-name squat the type_name guard required. Consequences says so. Also: PreparedStoreMutation has private fields and a same-crate constructor, so moving it down needs a public new() the record never stated; StoreSealIdentity keyed on a store_uid that does not exist, and zone_uid cannot distinguish two stores in one Zone, so it keys on store_uuid; the E0451 row held a literal ellipsis rather than a measured diagnostic, and the measured text names every private field, so the fixture asserts the stable suffix; ResourceService::new checks a necessary condition, not a sufficient one; MutationPort and mutation_port_rejects_submission_to_another_store are in no committed tree, so section 5 is a forward prohibition rather than a removal; d2b-contracts is also common to all three crates and is now named and rejected as a placement; OpenedMutation gains the accessors the writer and the census both require. Adds the alternatives a reviewer will ask for: a process-wide seal root claimed once, rejected because it needs the cfg(test) reset hatch that invariant 5 forbids; an unsafe fn mint, measured unavailable because unsafe_code is forbid at workspace level and forbid cannot be relaxed by the calling crate; and branded lifetimes, rejected because the store is held 'static in an Arc that outlives any brand-generating closure. Status returns to Proposed in the record and the index row. No panel round has run, and the ADR skill sets Accepted at land.
Panel round 1 raised two HIGH findings on disclosure and gate coverage and two MEDIUM findings on the error and identity surfaces. All four are answered from committed code rather than from argument, because the record's own history is that a plausible claim was believed once already. The seal types implement nothing. The record had planned a redacted Debug on the four capability types, on the SessionAcceptor precedent. That precedent is real and it is the wrong one here: VerifiedMutation, the exact type SealedMutation replaces, implements nothing at dc14502, and neither do RedbResourceStore, NativeAuthorizer, or ResourceService, the three types that would hold a seal. Nothing needs the impl, so adding one buys no diagnosis and leaves a live surface a later author can widen. Denial is also strictly stronger than redaction: tracing's ? sigil needs Debug and % needs Display, so a type with neither cannot be an event field, a span attribute, or a label at all. The leak moves from forbidden to unrepresentable. Measured on rustc 1.97.0: {:?} on a non-Debug type is E0277, and adding a Debug arm to the session crate's ambiguity construction turns a reintroduced derive into E0283. Both diagnostics are now asserted, by two new compile-fail fixtures and by the renamed in-crate assertion. Section 2a records the rule and the prohibition it implies; invariants 7 and 8 make it binding. The census claim that came with it was wrong in a way worth naming. capability_fixed_point grows the closure by referrer, not referent, so the four new roots pull in their holders and not the types they return. The approved trait-impl file therefore gains holder rows, one of which is NativeAuthorizer's already-redacted Debug, and no seal type may ever appear there. B2's stopping condition is now that grep, not an unverifiable claim of zero rows. Wave A cannot stop on make test-rust alone, and the gap is measured. test-rust excludes d2b-contract-tests, which is where policy_docs.rs enforces a 40,000-byte ceiling on AGENTS.md. That file is 38,812 bytes at this tip, so the critical-subsystem row Wave A adds has 1,188 bytes of headroom and could plausibly consume it, with no other lane to notice. The same crate holds the dash gate and the lints that read AGENTS.md and the ADR index. Wave A and B2 both now require test-fixture-contracts, spelled with D2B_ENABLE_FIXTURE_BUILD=1 because tests/test-rust.sh refuses to run the lane without it and a stopping condition a machine cannot evaluate is not one. take_store_seal gets its own error. StoreBindingError is a unit struct returned both for a second take and for a poisoned lock; one is a composition bug the operator fixes by building a second authorizer, the other is a process that must not continue, and the conflated value supports neither. StoreSealHandoffError separates them and carries the Zone as a typed field rather than as message text. The acceptor-install refusal splits into a zone code and a store code, because those have different remediations, and section 4 now states the remediation for each of the four codes. None of them may carry the store UUID: the types involved cannot be formatted, so a violation is a compile error. StoreSealIdentity keys on ResourceUid, not String. It already exists in d2b-contracts, which d2b-resource-store already depends on and which d2b-resource-store already names for subject_uid, so this costs no new type and no new dependency. Validation, the deliberate absence of normalization, and the reason its equality must not be made constant-time are recorded in section 2b, along with the boundary that keeps StoreMeta a String so a corrupt durable value still reports store-identity-mismatch rather than a decode error. Status stays Proposed. No validation claim was present in the record and none was added; the evidence for this branch belongs to the request that carries it.
Two round-2 findings close together because they are one surface. The mismatch type and the store correlator are both what an operator reads when a seal refuses, and the correlator is attached to the same reason codes the mismatch type selects; splitting them would have left each commit describing half of one message. The Rust finding: SealIdentityMismatch was a struct of zone_matches and store_uuid_matches. Four representable states for a two-state domain, one duplicating the Ok arm and one asserting a UUIDv4 collision across Zones. It is now a two-variant enum, diagnose returns Result<(), SealIdentityMismatch>, is_match folds into Result::is_ok, and the reason code becomes total. The product finding: every refusal denied with a stable code and no identity, which is right for a single store and unusable for a daemon that opens six. Redaction that is correct about identity leaves a multi-instance failure unlocatable unless a non-secret correlator is designed in beside it. New section 2c defines StoreSlot, a bounded zero-based index into the composition root's declared store list, mirroring the committed MutationOrdinal shape in the same file. It is assigned from configuration order and from nothing else, never from a UUID, path, authority address, hash, or completion order; an operator maps it back by counting entries in their own configuration. It rides inside StoreSealIdentity so it cannot disagree with the identity it names, is carried by StoreError and StoreSealHandoffError as typed fields, and open_owned refuses an acceptor whose slot disagrees with the store's, because a correlator that can name the wrong entry is worse than none. Nothing in section 2a is relaxed. The store UUID, the SealAuthority pointer, and the host database path stay prohibited on every surface, and the prohibition list gains the path explicitly. StoreSlot is bounded at 64, so it is label-safe; no metric, span, or log is added by this decision. mutation_seal.rs still renders nothing and now also implements no comparison trait, so a derive cannot fold the correlator into the identity check. Also restores the "### 3. How the direction works" heading, dropped by an earlier fix round, and records the round's friction row. Invariants 7 and 8 are rewritten; 10 and 11 are new. Sections 2, 2a, 3, 4, 6, and 7 follow, along with one compile-fail fixture measured on rustc 1.97.0, three added runtime negatives, one added policy count, and one added Wave A stopping condition. Status stays Proposed.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
d2b-resource-storeValidation
make check-tier0make test-changelogmake test-policyD2B_ENABLE_FIXTURE_BUILD=1 make test-fixture-contractsReview
The binding ten-seat ADR panel reached unanimous sign-off with no recommendations on the accepted ADR and synchronized index.