Skip to content

docs: define the D106 store-owned mutation seal - #367

Merged
vicondoa merged 13 commits into
v3from
adr-d106-mutation-seal
Aug 1, 2026
Merged

docs: define the D106 store-owned mutation seal#367
vicondoa merged 13 commits into
v3from
adr-d106-mutation-seal

Conversation

@vicondoa

@vicondoa vicondoa commented Aug 1, 2026

Copy link
Copy Markdown
Owner

Summary

  • accept ADR 0049, placing the unforgeable mutation seal in d2b-resource-store
  • bind single-use mutation evidence to the exact store and mutation while keeping protected identity out of observable surfaces
  • define implementation ownership, API-surface seals, policy lints, and enforcing validation for the Wave 5 fix

Validation

  • make check-tier0
  • make test-changelog
  • make test-policy
  • D2B_ENABLE_FIXTURE_BUILD=1 make test-fixture-contracts

Review

The binding ten-seat ADR panel reached unanimous sign-off with no recommendations on the accepted ADR and synchronized index.

vicondoa added 13 commits August 1, 2026 02:32
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.
@vicondoa
vicondoa merged commit f4a99ad into v3 Aug 1, 2026
59 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant