SectionCurrent referenceStatuslivingLast reviewed2026-09-07

N2-A — migration gate: collision scan, dispositions and partner invariants (#2627)

Status: living — design and evidence record for the N2-A tranche Truth class: descriptive Canonical: no — docs/architecture/IDENTITY_SEMANTICS.md owns the semantic contract and docs/architecture/n2-a0-stored-key-inventory.md owns the measured stored-key surface; this document owns only N2-A's dispositions and design Last reviewed: 2026-09-04 Source basis: live main at 2f9def5d5aca40c8b2c357df9abd2a731f542cc7 (§1 baseline rows still cite the 83682563 measurement they were taken at) Gates: N2-A / #2627 (Did canonicalization, I7) Contract: IDENTITY_SEMANTICS §3, §7.5, §11 (I7), §14 (N2-A)


Tranche state, in six separate claims.

  1. I7 code has landed. Did PartialEq/Eq/Hash compare the decoded identifier bytes (#2686, 0defbde5). The design in §6 was applied; Display/as_str/Serialize are byte-identical to before, and no persisted byte moved. The sentence this document carried until 2026-09-02 — "Did Eq/Hash is unchanged and must stay unchanged" — was stale from that merge onward and is corrected here.
  2. The fail-closed check now lives inside the binary (§10). icnd runs the N2-A startup gate over every sled database beneath its data directory before it opens the first one, and refuses to start over an unruled alias collision, an uncovered principal row, an unreadable row, or a receipt from a newer generation. This discharges §3.5's consequence and §12.1 item 7 of the inventory for the binary; it is not deployment evidence.
  3. The ledger's own loaders classify before they adopt (§4.1). The three principal-keyed rebuilds in Ledger::new — ledger:balance:, ledger:cleared_volume:, ledger:frozen: — classify their whole keyspace and refuse with a typed PrincipalRowsRefusal rather than collapse two spellings of one account, whether or not the startup gate ran in front of them. That is the icn-ledger half of §9 row 3. It authorizes no merge rule; §10.6 states how the gate and the loaders divide the work.
  4. The first derived projection is dispositioned as a projection, and the remaining boundaries are classified by the mechanism that proves them (§11). The icn-federation agreement party index (idx_agreement_party/, §4 row 20) is written only from the canonical federation/agreements/ rows, and since #2707 its store answers a party lookup from canonical membership under Did equality, retires superseded rows, refuses what it cannot attribute and can rebuild the projection — so a two-spelling pair there is registered Equivalent, a redundancy and not an authoritative conflict, and the startup gate clears it under the same descriptor. §11.1 states the persistence-boundary classes P1–P5 that keep that case apart from the attestation store's fail-closed rows and the ledger's guarded folds; §11.4 dispositions the rest of the inventory by class. Nothing in §11 authorizes a merge rule for authoritative state.
  5. The treasury loader classifies before it adopts (§4.2, #2627 M1). TreasuryManager::with_store classifies every primary ledger:treasury:<did> row through the same icn_ledger::principal_rows guard, proves the key spelling and the body spelling are the same bytes, checks the persisted cooperative index against the primary rows, and refuses with a typed error before the first in-memory map is touched — whether or not the startup gate ran in front of it (icnctl opens the same store with no gate). The primary row is registered in the scanner as icn-ledger/treasury, fail closed, so the gate refuses the same alias pair and an ordinary treasury row is no longer uncovered; the audit and budget-index siblings that embed a spelling remain uncovered and are the next treasury follow-up. It authorizes no merge rule.
  6. Cutover is not complete. The rest of the load/rebuild/write-back audit (§9 row 3: the loaders in §6.5 and the boundaries in §11.4), fresh point-in-time evidence on quiesced stores, the two unscanned deployments, the §5 decision-A namespace splits, the peer-map pair (§6.3) and everything behind §7.5 remain open. Nothing here is a deployment-readiness claim.

This document is the design and evidence surface for N2-A. It does not migrate any store or discharge the §7.5 membership/vote gate. It records what was measured, what was decided, what is enforced, and what is still missing — deliberately in separate sections, so a decision is never mistaken for evidence.

Companion documents:

  • docs/architecture/IDENTITY_SEMANTICS.md — §11 I7, §14 DAG node N2-A. Owns the contract.
  • docs/architecture/n2-a0-stored-key-inventory.md — the N2-A0 inventory (#2623). Owns the keyspace list this document dispositions.

1. Baseline

Item Value
Merged main at entry 836825632ebb5b7b9d8d16354974503a7c576569
Inventory measured at 798c8d54
Did Eq/Hash principal-keyed since #2686 — compares the decoded 32 identifier bytes (icn/crates/icn-identity/src/lib.rs)
Governance #2641 / PR #2677 merged; runtime vote interpretation is principal-safe, no persisted byte moved

1.1 Drift from the prior evidence pass

The prior pass listed inventory row #1 (icn-net replay state) as "migrate in tranche, merge rule = max floor". That is now partly discharged in-tree and the row's hazard has changed shape rather than disappearing.

PRs #2644, #2647 and #2649 re-keyed icn-net replay protection onto a SenderPrincipal (icn/crates/icn-net/src/replay_guard.rs), and the load path already:

  • folds several spelling-distinct rows for one sender into one window, taking the maximum floor, so a lower floor cannot win;
  • declines to collapse rows whose readable interpretations disagree, preserving each interpretation for the load pass rather than electing a survivor;
  • rewrites survivors onto a canonical key only where a single interpretation is established.

Two consequences for N2-A:

  1. Rows #1–#3 no longer need N2-A to invent a merge rule — the live loader already implements one, and its replay_sender_regime behaviour is fail closed, matching §12.1 item 7.
  2. SenderPrincipal keys on VerifyingKey, not on decoded identifier bytes. Roughly half of anchor-derived DIDs do not decompress to an Edwards point (inventory §2.3), so a DID that Did::identifier_bytes resolves may be one SenderPrincipal rejects. The two principal notions are therefore not interchangeable, and N2-A must not assume replay-guard coverage extends to anchor-derived principals.

Row #2 (icn-net outgoing sequence tracker) is unchanged: its key is still built from sender.as_str() / recipient.as_str() (icn/crates/icn-net/src/sequence_tracker.rs).

No other merged PR since 83682563 touched an N2-A migration surface.


2. The collision scanner (evidence tool)

2.1 What it is

icn/crates/icn-store/src/did_collision_scan.rs — a reusable, read-only scan engine, plus a runner binary icn/crates/icn-store/src/bin/did-collision-scan.rs.

It is not a one-off script and it is not a migration. It answers one question per keyspace: do two stored rows name one principal, and what happens when they merge?

2.2 Design properties, and why each is load-bearing

Property Mechanism Why it matters
Decode-faithful Groups by icn_identity::identifier_bytes_of_spelling, the function Did::identifier_bytes itself delegates to A scan that grouped by a reimplementation of the decode would prove nothing about the equality it gates
Read-only Copies the store directory to scratch and opens the copy; the source is never opened sled::open takes an exclusive lock and runs recovery writes on an unclean directory. A tool that opened a live store directly would violate its own guarantee
Payload-free Values are reduced to their length at the scan boundary; principals appear as an 8-hex-character fingerprint The report is an operational artifact that will be pasted into issues
Layout-independent DID spellings are located by scanning for the did:icn: scheme, not by parsing each keyspace's separators A keyspace that changes its separator cannot silently fall out of scan coverage
Falsifiable Always reports total store rows, namespace counts, and per-tree row counts alongside the per-keyspace zeros "0 rows" from a broken scanner and "0 rows" from an empty store are otherwise indistinguishable
Gate-shaped Exit status 0 only when every principal-bearing row is accounted for and every keyspace accounting for one is automatable Makes the tool usable as the migration gate, not merely as a report. The verdict lives in CoverageAudit::is_clear, and the runner renders it rather than recomputing it, so exit status and report cannot disagree

2.3 The three-state accounting — and why there is no fourth

Every principal-bearing row in a scanned store is in exactly one of three states, and the verdict follows from that partition:

  1. Covered — a registered keyspace interpreted it, so the collision result speaks for it.
  2. Deferred — a named gate owns it: n2a_deferred_namespaces records the governance vote keyspace (§7.5), the auth-challenge namespace and the security namespace (dedicated workflow). Deferred is neither dispositioned nor cleared; it is a reviewed exclusion, reported separately and never folded into the scanned counts. Since 2026-09-02 a deferred namespace's rows are still grouped by principal — deferral names who owns the merge rule, and looking away from the data would let a collision reach the loader unexamined — and each deferral carries a DeferredCollisionPosture saying what a starting binary does about a collision there (§10.2).
  3. Uncovered — nothing accounted for it, which blocks.

State 3 is the point. A keyspace added after this tool was written, or simply left out of the registry, is indistinguishable from a clean store unless unaccounted rows block — and that has already happened once: §5 rows #71 and #36 were live and unregistered (§3.4). Without the deferral registry the only safe verdict on any uncovered row would be "blocked", which would make the gate unusable; with it, an accidental omission still blocks while a reviewed exclusion does not.

A deferred namespace is never dispositioned: its report carries FailClosed / AwaitingDomainSignOff by construction, so nothing downstream can read a merge rule out of it. Its key shapes are grouped exactly as a registered keyspace's are; its values are never read.

2.4 A plausible merge rule is not an authorized one

The disposition table below distinguishes rules that are established from rules this document merely asserts. That distinction is now encoded, not just written down: KeyspaceDescriptor::basis carries RuleBasis::Established or RuleBasis::AwaitingDomainSignOff, and a collision under an unsigned-off rule is not automatable regardless of its disposition.

This closes a real contradiction found in review. §4 row 5 said summing ledger balances still needed economics sign-off, while the code marked that keyspace Sum — so a generic storage crate would have authorized a merge of monetary state on nothing but its own say-so. Seven keyspaces are currently AwaitingDomainSignOff: icn-ledger/{balance,cleared_volume,frozen}, icn-net/outgoing_seq, icn-trust/edges, trust-app/sequences_{issuer,receiver}. A test pins that list against this document so the two cannot drift. sequences_receiver moved into this set on 2026-09-03: its max rule had been marked established "by precedent", but precedent is not implementation — the receiver tracker reads and writes the exact spelling and folds nothing, so the gate now refuses a collision there rather than clearing it on a rule no loader performs.

The basis only bites when there is something to merge; a keyspace with no collisions stays automatable whatever its basis.

2.5 What a CLEAR verdict is conditional on

A recursive file copy of a live store is not a point-in-time snapshot. Writes can land between one file being copied and the next, so sled may recover the copy successfully while omitting a row that existed in the source — including an aliasing row. A CLEAR verdict therefore describes a state the source may never have held at any single instant.

This is a limit of the evidence, not a defect to code around: quiescing a store means stopping a workload, which this tranche must not do. It is printed with every report and carried in the JSON. For a verdict that is binding rather than indicative, scan a quiesced store or a coherent volume snapshot with writes held until the cutover — and note that this is a further reason the fail-closed check belongs inside the key-equality binary (§3.5).

2.6 Fail-closed reads

Two paths could previously turn missing evidence into apparent absence, and both now propagate:

  • Tree iteration. tree_row_counts and did_bearing_rows_per_tree propagate a sled iterator error instead of discarding the unreadable item. The case that matters: if the unreadable key were the only principal-bearing row in a named tree, discarding it would drop the count to zero and pass a store that was never finished. Corruption and an incomplete copy are evidence, not absence.
  • Spelling extraction. The tokenizer takes the longest candidate run, then shortens it from the right until it decodes to a 32-byte identifier. The alphabet must include + and / — the production parser accepts all 23 multibase::Base encodings and Base64 spellings contain both — but / is also a live key separator (trust/edges/<did>), so the alphabet alone cannot say where a spelling ends. Deciding by decoding rather than by alphabet captures a base64 spelling whole and still terminates <did>/suffix at the spelling. Nothing that fails to decode is silently dropped: the whole run is reported as one unreadable token, which itself blocks.

2.7 Grouping rule

Rows are grouped by their principal-canonical shape: the raw key with every embedded did:icn: spelling replaced by the 32 identifier bytes it decodes to. Non-DID key material stays in the shape, so:

  • ledger:cleared_volume:<did>:USD and …:EUR stay apart, while two spellings of that account under USD come together;
  • tuple keys such as outgoing_seq:<sender>||<recipient> collide only when both ends resolve to the same pair, and the per-position representation counts say which end was re-spelled.

Rows within a group are reported in Store::scan order — lexicographic by key bytes — because that order decides the survivor of every last-writer rebuild. Base256Emoji spellings are non-ASCII and therefore sort after every ASCII spelling, so the survivor is attacker-selectable. The scan surfaces the survivor explicitly rather than leaving it implicit.

2.8 Coverage limit found while building it

Store::scan reads only sled's default tree. icn-gateway's service discovery uses a named tree, which a Store-trait scan can never reach. A scan reporting zeros on such a store would be a false negative.

The runner therefore also reports per-tree row counts and per-tree DID-bearing row counts (SledStore::tree_row_counts, SledStore::did_bearing_rows_per_tree), and treats principal-keyed rows in a named tree as blocking, not as a clean result.

2.9 Fixture tests

17 tests in did_collision_scan::tests, run against a real SledStore rather than a hand-rolled double — the ordering claim is a claim about the actual backend, and a simulated store would only restate the test's own sort. They cover: no-collision data; two representations of one principal; several representations; malformed/unreadable keys; a group with a known merge rule; a group with no authorized merge rule; residual key fields; tuple keys; scan-order survivor; the non-ASCII survivor case; read-only-ness; payload absence; and registry scope.

Discrimination evidence. Mutating the grouping to today's spelling-keyed behaviour (group by raw key instead of canonical shape) fails 9 of 15 collision tests and leaves green exactly the 6 that should not depend on grouping. The suite therefore discriminates rather than passing vacuously.

2.10 How to run it

cd icn && cargo build -p icn-store --bin did-collision-scan
./target/debug/did-collision-scan <sled-db-path> [<sled-db-path> ...] [--json]

Each path must be a sled database root, not a directory above one. A deployment keeps one database per domain, so /data is the wrong level and /data/store/ledger is the right one. The tool refuses a wrong-level path and names the databases beneath it, because sled::open on a non-database directory creates one — which would otherwise mean zero rows and a false CLEAR.

Against a Kubernetes deployment, extract the volume first — the tool must be given a directory it can copy:

kubectl cp <namespace>/<pod>:/data ./deployment-data
# one database per domain — scan each, not the parent
find ./deployment-data -name conf -printf '%h\n' | xargs ./target/debug/did-collision-scan

Exit status: 0 clear, 1 at least one keyspace or blocking deferred namespace must fail closed, 2 tool error. The verdict is SledStoreAudit::is_clear — the same computation the startup gate enforces (§10), so what the offline tool reports is what the binary will do.


3. Scan coverage actually achieved (evidence)

This section records only what was measured.

3.1 Deployments scanned

Extracted read-only with kubectl cp from the running pod and scanned from the copy; the live volumes were never opened. Extraction date 2026-08-29.

Deployment Extracted sled DBs scanned Result
icn-coop-beta yes 32 scanned
icn-coop-gamma yes 31 scanned
icn-coop-delta yes 31 scanned
icn-coop-alpha no — container CrashLoopBackOff, not attachable
icn / icn-daemon no — container CrashLoopBackOff, not attachable

94 sled databases scanned in total. Workloads were not restarted to obtain evidence.

A store topology finding worth recording: a deployment does not keep one shared store. It keeps one sled database per domain under /data/store/ (ledger, network, trust, governance, security, cooperative, gossip, …) plus gateway_store, commons.sled, federation_store, identity_store, and state.snapshot* files. Encrypted keystore material (identity.age*) is present and was not read.

3.2 Aggregate result

Measure Value
Rows matched by registered N2-A keyspaces 24
Collision groups 0
Rows participating in collisions 0
Unreadable / malformed DID keys 0
Named-tree coverage gaps 0
Keyspaces requiring manual disposition 0
Keyspace Rows Collisions
icn-trust/edges 12 0
icn-net/outgoing_seq 4 0
icn-coop/member 3 0
icn-net/replay_max_seq 2 0
icn-net/replay_sender_regime 2 0
trust-app/sequences_issuer 1 0

Every other registered keyspace matched zero rows, and those are real absences: each store's total row count and per-tree count were reported alongside, and the raw sled files of the empty local stores were independently checked for did:icn: byte occurrences (zero) to distinguish an empty store from a failed read.

Registry scope at the time of this scan. The registry then held twelve keyspaces. federation/attestations/ (§4 row 19, #2703), idx_agreement_party/ (§4 row 20, #2707) and the primary ledger:treasury:<did> row (§4 row 21, #2627 M1) were registered afterwards and are not represented in these figures; any row of any of them present then would have counted under uncovered (§2.3), and none did. See the row-19, row-20 and row-21 notes in §4 for what that does and does not show. A registry expansion does not retroactively enlarge this evidence: the historical scan scope is what was registered then, and the current registry scope is what is registered now.

3.3 Principal-bearing rows behind a named gate — 63

A per-keyspace zero only speaks for the rows that keyspace matched. Reconciling all DID-bearing rows against the registry found 63 the registry does not cover. Each is now accounted for by a named gate (§2.3 state 2) rather than left as an unexplained remainder:

Family Rows Why out of scope
gov:vote:<uuid>:<did> 32 inventory row #23 — §7.5 gate, not N2-A
security:reputation:<did> 10 security namespace — deferred to the dedicated security workflow
security:violation:<did> 10 as above
security:banned:<did> 7 as above
security:quarantine:<did> 4 as above

The icn-rpc auth-challenge namespace (auth:challenge:<did>, inventory row #29) is also a registered deferral. No live challenge existed in the scanned deployments — they are TTL-bounded — but without the entry an ordinary challenge row would have been classified uncovered and blocked the gate.

These 63 rows are deferred, not cleared. Their existence and their migration dependency are recorded; their contents were not inspected.

Re-running the scan after the deferral registry landed reconciles exactly: 24 covered + 63 deferred = 87 principal-bearing rows, 0 uncovered, 0 blocked, across all 94 databases. The three-state partition holds on real data, which is the property the verdict depends on.

3.4 A registry scope error the scan caught

The first registry was built from the inventory's §12 Concrete list — the NEEDS MIGRATION rows only. Scanning real data found live rows in two keyspaces classified SILENT-MERGE RISK in §5 and therefore absent from that list:

  • trust/sequences/issuer/<did> and .../receiver/<did> — inventory row #71;
  • member:<coop>:<did> — inventory row #36.

SILENT-MERGE RISK is precisely the class that merges without announcing itself, so scoping the scanner to NEEDS MIGRATION alone was wrong. The registry now covers both classes, and the three keyspaces are included in the §3.2 figures above.

This is the concrete value of reporting uncovered principal-bearing rows by masked key shape rather than reporting per-keyspace zeros: the gap was found from data, not from re-reading source.

3.5 What this evidence does and does not establish

Does: no aliased principal rows exist today in any registered N2-A keyspace of the three scanned deployments, and no malformed or unreachable principal rows exist there either.

Does not:

  1. Two deployments are unscanned (alpha, icn-daemon).
  2. The sample is small. 24 principal-bearing rows across a handful of principals is a pilot cluster, not a populated production deployment. Absence of collisions here is weak evidence about a large store.
  3. The result is point-in-time. Aliasing is attacker-chosen (§2.2) and from is unsigned, so a peer can write an alternate-spelled row at any time. A clean scan today does not imply a clean store at migration time.

Consequence for the migration design: the scan must be re-run immediately before the cutover, and the fail-closed check belongs in the binary — a key-equality build should refuse to start against a store whose rows alias under an unruled keyspace, rather than trusting a scan run earlier. Implemented as the startup gate (§10) on 2026-09-02, and enforced again for the three icn-ledger keyspaces inside Ledger::new (§4.1, §10.6); the point-in-time limits above still apply to any evidence gathered outside the binary.

4. Keyspace dispositions (decisions)

Merge rules are stated per keyspace and encoded in the scanner registry (did_collision_scan::n2a_keyspaces), so a scan reports the rule alongside the collision. No rule was invented to unblock the tranche: where two rows may encode contradictory state and no domain rule authorizes choosing or combining them, the disposition is fail closed.

# Keyspace Key encoding Collisions observed Merge rule Rule already established by domain semantics? Lossless? Alias/dual-read window? Order Rollback Tranche Status
1 icn-net replay_max_seq: Display 0 in 3 scanned max floor yes — implemented in replay_guard (#2644) yes no 4 safe (no byte moves) N2-A rule live in-tree
2 icn-net replay_finalized: Display 0 in 3 scanned union yes (#2644) yes no 4 safe N2-A rule live in-tree
3 icn-net replay_sender_regime: Display 0 in 3 scanned fail closed yes — loader already declines to collapse n/a no 4 safe N2-A fail-closed by design
4 icn-net outgoing_seq: as_str ×2 0 in 3 scanned max no — asserted here yes no 4 safe N2-A rule needs review
5 icn-ledger ledger:balance: JSON-quoted 0 in 3 scanned sum no — asserted here yes no 4 safe N2-A rule needs economics sign-off
6 icn-ledger ledger:cleared_volume: Display + currency 0 in 3 scanned sum no — asserted here yes no 4 safe N2-A rule needs economics sign-off
7 icn-ledger ledger:frozen: Display 0 in 3 scanned union no — asserted here yes no 4 safe N2-A rule needs review
8 icn-trust trust/edges/ as_str ×2 0 in 3 scanned union no — asserted here yes no 4 safe N2-A rule needs review
9 icn-ledger ledger:journal: content hash 0 in 3 scanned equivalent yes — key carries no spelling yes no — safe N2-A scanned to confirm
10 icn-security misbehavior Display not inspected — — — — — — security workflow deferred; migration dependency preserved
11 icn-rpc auth challenges Display not inspected — — — — — — security workflow deferred; TTL-bounded
12 apps/governance votes (#23) Display 0 in 3 scanned §7.5 re-key n/a n/a required after N2-A n/a §7.5 gate not N2-A
13 icn-commons weak-holder id (#65) SHA-256 of spelling I7 creates the split — no no — before 6 n/a N2-A namespace decision — see §5; the mint is guarded before persistence and the derivation is unchanged (§11.7, #2627 M3) — existing derived ids are not migrated
14 VectorClock (#45), snapshot vector_clock (#54) serialized map 0 in 3 scanned max yes — VectorClockProjection::from_entries yes no 4 safe N2-A rule established
15 snapshot peer_connections (#57) serialized map 0 in 3 scanned fail closed no no — 4 safe N2-A no authorized rule
16 trust-app trust/sequences/receiver/ (#71) Display 0 in 3 scanned max no — asserted by precedent; SequenceTracker reads and writes the exact spelling and implements no fold yes no 4 safe N2-A rule needs a trust-domain loader that folds; fail closed at the gate until then
17 trust-app trust/sequences/issuer/ (#71) Display 0 in 3 scanned max no — asserted here yes no 4 safe N2-A rule needs trust-domain confirmation
18 icn-coop member: (#36) Display 0 in 3 scanned fail closed no n/a — — safe N2-A / §7.5 boundary institutional decision required
19 icn-federation federation/attestations/ (#27, #59) as_str + / + source coop not measured — outside the registry when §3 was scanned (#2703) fail closed yes — the live store refuses to read, write or sweep over such a pair, and revokes one atomically (#2704); the merge rule itself is undecided n/a no — safe (no byte moves) N2-A fail-closed in code and at the gate; merge rule awaits a federation-domain decision
20 icn-federation idx_agreement_party/ (#28) as_str + / + agreement id not measured — outside the registry when §3 was scanned (#2707) equivalent (projection) yes — the rows are derived from federation/agreements/ and the store proves membership from the canonical row, retires superseded rows, refuses what it cannot attribute and can rebuild the projection (§11.3) yes no — safe (no byte moves) N2-A registered Equivalent/Established in code and at the gate; projection, not authority
21 icn-ledger ledger:treasury:<did> (#10, #41) Display not measured — outside the registry when §3 was scanned (#2627 M1) fail closed yes — the loader classifies every primary row and refuses an alias pair, an unreadable key or value, a key/body spelling disagreement and a disagreeing cooperative index before adopting anything (§4.2); the merge rule itself is undecided n/a no — safe (no byte moves) N2-A fail-closed in code and at the gate; no merge rule authorized
22 icn-commons commons/holders/by_did/ (#67) Display not measured — outside the registry when §3 was scanned (#2627 M3) fail closed yes — two spellings reach two independent CommonsHolderRecords with their own status, personhood level and rights, so a merge decides a member's standing; the live mint seam refuses the same state before it can be created (§11.7) n/a no — safe (no byte moves) N2-A fail-closed in code and at the gate; no merge rule authorized, existing duplicates not dispositioned
— CompressedVectorClock (#46) dormant n/a derive-shape fix n/a yes no 3 safe N2-A no data step

Rows 10 and 11 are security-specific namespaces. Their existence and migration dependency are preserved here; their contents were not inspected and their disposition belongs to the dedicated security workflow, not to this tranche.

Row 18 (icn-coop cooperative membership) is new to this table. Merging two membership rows decides who is a member of an institution, which no identity-layer rule authorizes, and it sits next to the §7.5 membership gate without the inventory having placed it there. It is therefore fail closed pending an explicit governance-domain decision about which side of the §7.5 boundary it falls on. N2-A must not resolve that by default.

Row 19 (icn-federation federated trust attestations, #2703) was outside the registry when the §3 evidence was gathered, so the three-deployment result says nothing about it. Its key is federation/attestations/<member-did spelling>/<source_coop_id> and its collision unit is (member principal, source cooperative), because the source stays in the canonical shape: rows from different cooperatives about one principal are the federation's ordinary union and never a group, while two rows from one cooperative about one principal can only differ by disagreeing. No federation-domain rule authorizes choosing or combining such a pair, and this document authorizes none: the disposition is fail closed. Since #2704 the store itself enforces that posture — it reads the whole namespace by Did equality rather than by spelling prefix, refuses any operation that would interpret or mutate an ambiguous pair (a lookup for that principal, a listing for that source, a write to that pair, the expiry sweep), surfaces an unreadable or key/value-inconsistent row as a typed error instead of skipping it, and revokes every alias row a removal names in one atomic deletion, so an interrupted revocation cannot leave a lone alias row that the next read would accept. It re-keys, merges and normalizes nothing; persisted bytes are unchanged.

The startup gate (#2700, merged) consumes the same n2a_keyspaces() registry, so a federation alias pair refuses the node start exactly as any FailClosed keyspace does; the gate fixtures proving it are in icn/crates/icn-store/tests/n2a_startup_gate.rs, with a one-fact-different control in which the two spellings carry different source cooperatives and the start is clear. The descriptor states the key structure rather than letting the scan guess it: the member spelling is anchored immediately after the prefix and ends at the /, and everything after that / is an opaque discriminator compared byte-for-byte. The source is a federation-domain identifier this registry does not own, nothing forbids one that contains did:icn:, and the store compares source ids as exact strings — so a scan that canonicalized inside the source would group rows the store holds apart and call rows unreadable that the store reads without difficulty. A populated attestation row in a store scanned before this registration could only have surfaced as an uncovered shape (§2.3) — blocking, but unclassified. §3.3 reported zero uncovered rows across the three scanned deployments, which is consistent with those stores holding no attestations at the time and is not a measurement of this keyspace. The first scan that includes it yields new evidence, not a regression.

Row 20 (icn-federation agreement party index, inventory #28, #2707) is the first keyspace dispositioned as a projection rather than as authoritative state, and the distinction is the point of §11. Its key is idx_agreement_party/<party-did spelling>/<agreement id> and its value repeats the agreement id; every row is written from the canonical Agreement.parties vector and from nothing else, so two spellings of one party for one agreement are two derivations of one canonical fact. Equivalent is the existing vocabulary for exactly that ("the rows are equivalent by construction; keeping any one loses nothing") and no new disposition was invented for it. The rule is Established because the live store now enforces it: a party lookup reads the whole projection, keeps the rows whose spelling names the queried principal, and returns only agreements whose canonical parties contain that principal under Did equality — so an index row can never create, omit, preserve or alter membership on its own. What the scanner sees under this prefix is a redundancy, never a contradiction, which is why a collision here is automatable.

The descriptor is the attestation layout's shape under the opposite disposition: the party spelling is anchored immediately after the prefix and ends at the /, and the agreement id after it is an opaque discriminator compared byte-for-byte — an identifier the agreement's creator chose, which the registry does not own and the scan never parses, so an id that contains or is a did:icn: spelling is still just an id, and no agreement id is normalized. The collision unit is therefore (party principal, exact agreement-id bytes): one party in two agreements is two shapes and never a group; only two spellings of one party for one agreement group, as an Equivalent, automatable pair. The startup gate (§10) consumes the same descriptor, so such a pair is recorded and the start is clear, an unreadable party spelling refuses, and no row is moved, rewritten, normalized or deleted; the fixtures are in icn/crates/icn-store/tests/n2a_startup_gate.rs. A CLEAR there means this spelling collision is safe under the registered projection disposition. It does not mean the projection is complete, current or authoritative — §11.2.

Row 21 (icn-ledger treasury record, inventory #10 and #41, #2627 M1) is the first P4 fold (§11.1) closed after §11.4 named it the highest-severity open boundary. Its key is ledger:treasury:<treasury-did spelling> with nothing after the spelling, and its collision unit is the treasury principal alone. Two rows for one principal are two treasury records that can disagree about every field; no economics rule authorizes choosing, summing or combining them, and this document authorizes none: the disposition is fail closed, Established because the loader implements it. The descriptor's prefix runs through the DID scheme (ledger:treasury:did:icn:) so that it claims exactly the primary rows and none of the budget, rule, audit, index or velocity-limit siblings that share the lexical parent — two of which embed the spelling as key structure and remain uncovered until dispositioned on their own. §4.2 records the loader, the key/body rule, the cooperative-index rule and the gate agreement.

Rows 14–15 hold DIDs inside serialized values, not keys, so they are not prefix-scannable and are not covered by the scanner registry. Their merge rule must be chosen before decode collapses them (§12.1 item 4-ii).

Every "unmeasured" cell is a direct consequence of §3.1 and is the substance of the open gate.

4.1 The ledger loaders now enforce their own dispositions (#2627)

Rows 5–7 name three keyspaces whose disposition is "fail closed until the economics owner signs off". Until this change that disposition existed in the scanner registry, in this table and — since #2700 — in the startup gate (§10), which refuses to start icnd over a collision there before any store is opened. It bound nothing at all inside icn-ledger: a Ledger::new reached with no gate in front of it — every embedder and test that is not icnd — would have collapsed a collision silently, whatever the registry said; and the gate never reads a value, so a row it cannot see as a problem was adopted unexamined. icn_ledger::principal_rows closes that gap: the three rebuilds classify their whole keyspace before adopting a row and refuse with a typed PrincipalRowsRefusal rather than electing a survivor. §10.6 states how the two layers divide the work.

The audit that produced it found the collapse is worse than last-writer-wins, in a way worth recording because it generalises to every HashMap<Did, _> rebuilt from spelling-keyed rows:

  • HashMap::insert replaces the value but retains the first-inserted key. Rows arrive in Store::scan order — lexicographic by key bytes — so the surviving value comes from the byte-greatest row while the surviving key spelling comes from the byte-least one. The two halves of the in-memory entry come from different rows.
  • save_cached_balances then re-derives the key from that map key and writes the other row's balances under it, leaving the byte-greatest row on disk holding stale state. The next start reads both again. The divergence is permanent, silent, and repeats every boot.
  • save_* is also reached from recompute_balances, whose maps are keyed by the spelling the journal entries carry rather than the spelling the stored row uses — so a write-back could open a second row for an account that already had one. Both rebuild paths now adopt the stored spelling (HashMap::get_key_value) instead of re-deriving one.
  • FreezeManager::remove_frozen deleted only the caller's spelling, so an unfreeze naming a second spelling left the freeze row live and the member came back frozen on the next start — a revocation that does not revoke. Removal now goes through HashMap::remove_entry so it deletes the row the map actually held.

Refusal is scoped as narrowly as the semantics allow: two currencies of one account are two rows of state and not a collision, and an expired freeze row is not live state, so a lapsed alias does not block a start.

A fourth refusal has no row in the table above because nothing writes it deliberately: a ledger:balance: row whose key spells the account differently from the account_id inside the row. That is the residue of a collapse that already happened, and loading it would adopt one row's money under another row's name.

This discharges the icn-ledger half of blocker 3 (§9). It authorizes no merge rule: rows 5–7 stay AwaitingDomainSignOff, and a test that asserted a sum or a union would be asserting an economic decision no domain owner has made.

4.2 The treasury loader classifies before it adopts (#2627 M1)

§11.4 classified ledger:treasury:<did> as a P4 fold over P1 rows and the highest-severity open boundary: TreasuryManager::load_from_store folded every row into HashMap<Did, Treasury> plus the Did-keyed coop, entity, budget and rule indexes, and its two fail-closed hydration guards compared Dids — principal equality since I7 — so two spellings of one treasury were not an inconsistency to them. The defect was reproduced on unchanged main before it was fixed, with a real sled store holding ledger:treasury:<A> and ledger:treasury:<B> for one principal, each row individually valid and naming the same cooperative:

  • hydration succeeded, and one semantic entry survived;
  • the surviving value was always the scan-last row — z… over f…, f… over F… — so Store::scan order, which whoever writes the second row chooses, elected it;
  • the coop index collapsed to one entry naming that survivor, and both spellings answered is_treasury_account;
  • a later write-back through the public populate_entity_id_at_creation seam rewrote only the survivor's row; the loser stayed on disk, stale, to be read again on the next start.

Mechanism. The loader now reads everything before it adopts anything:

physical rows beneath ledger:treasury:
→ classify every key by shape: primary / sibling subspace / unreadable
→ read a value only behind a key that names a treasury principal
→ prove one spelling per principal            (icn_ledger::principal_rows, unchanged)
→ prove key spelling == body spelling, as bytes
→ prove one treasury per coop_id and per entity_id
→ check every ledger:treasury:idx:coop: row against the classified primaries
→ only then adopt into the Did-keyed maps

Every refusal is typed and payload-free — PrincipalRowsRefusal::{AliasCollision, UnreadableKey, KeyValueSpellingMismatch} for what the treasury shares with the three §4.1 keyspaces, and a treasury-local TreasuryHydrationRefusal::{UnreadablePrimaryValue, DuplicateCoopId, DuplicateEntityId, CoopIndexUnreadable, CoopIndexSpellingMismatch} for what it does not — and every refusal is raised before the first map mutation, so a failure on row N never leaves the maps hydrated from rows 1..N−1 (pinned in-module against the private maps; every scan, the sibling subspaces included, completes before adoption begins). principal_rows itself gained one constant, TREASURY_KEYSPACE; its guard and its three existing callers are unchanged.

Key/body identity is physical, not semantic. persist_treasury derives the key from treasury.treasury_did's Display, so the row the writer claims to have written is the one whose key bytes equal its body's spelling bytes. The comparison is as_str() != key_spelling, never Did equality — under I7 key A, body B for one principal compares equal, and accepting it would adopt a row every later persist_treasury addresses under the other spelling, opening a second row while the first stayed on disk. A single such row refuses; its control (key f, body f) loads, because a non-base58 spelling is a legal stored spelling and nothing canonicalizes.

Unreadable primary rows refuse; they do not vanish. A key beneath the parent that is neither a registered sibling prefix nor a Did::from_str-valid spelling is UnreadableKey; a primary row whose value does not deserialize is UnreadablePrimaryValue; both are reported before the alias classification, because a classification over an incomplete view proves nothing. One consequence deserves its own sentence: an anchor-derived cooperative treasury whose 32 bytes are no Ed25519 point — about half of them, inventory §10.1 — fails Did::from_str at the key and Deserialize at the value. Before M1 the loader skipped such a row with if let Ok(..) and the treasury silently dropped out of the maps on every reload; after M1 hydration refuses it, and a node holding such a row does not start. M1 makes that state visible; it does not repair it, and the read-path fix stays with #2628 (N2-B).

Sibling subspaces are classified by key shape, never by whether a value parses. The lexical parent is shared with budget:, rule:, audit:, idx:coop:, idx:budgets: and vlimit: rows. The last was missing from the loader's old skip list and survived only because a velocity limit does not deserialize as a Treasury; it is now named, and a vlimit: row whose value does parse as a treasury record is still a sibling. A key beneath the parent that begins with none of the six is a primary row and must name a principal, so an unnamed subspace refuses hydration rather than being tolerated by accident. The sibling loaders themselves are unchanged and still skip a row that does not parse — pre-existing permissiveness, recorded here as follow-up and not absorbed.

ledger:treasury:idx:coop:<coop_id> is a write-only projection, and M1 gives it no authority. persist_coop_index writes it at registration; hydration rebuilds the coop map from the primary rows and nothing reads the index. It is still persisted evidence that can preserve a representation disagreement, so before adoption every index row must decode as a treasury principal spelling (else CoopIndexUnreadable) and must agree byte-for-byte with the physical key spelling of the primary row it names — whether it names it through the coop id it is filed under or through the principal its value decodes to (else CoopIndexSpellingMismatch). The fixture the reassessment required — primary A, index → B, one principal — refuses; its control (index → A) loads; an index pointing at a different registered treasury refuses by the same byte comparison; an orphan index naming no known coop and no known principal is tolerated, adopted from nowhere, and grants nothing. The order in which the rows were written changes no outcome.

The write path cannot open an alias pair. Reproduced before concluding: with one treasury hydrated, register_treasury and register_treasury_with_entity under the other spelling — with the same coop id or a new one — are refused by the existing contains_key check, which is principal equality, and write no row; populate_treasury_entity_id_for_did locates the row by principal and persists under the record's own spelling, so addressing it by the alias rewrites exactly the stored row. No writer-side guard was needed and none was added.

Startup gate. The primary row is registered as icn-ledger/treasury (§4 row 21): FailClosed, Established — the disposition the loader implements — PrincipalRegion::WholeKey with did_ends_key, and prefix ledger:treasury:did:icn:. The prefix runs through the DID scheme deliberately: it matches every key persist_treasury writes and no key beneath any sibling subspace, so the descriptor claims exactly the primary rows and says nothing about the two siblings that embed a spelling as key structure. Those — audit:<did>:<ts>:<id> (inventory #70) and idx:budgets:<did>:<budget> — keep the status they had before M1: uncovered to the gate, which refuses a store that holds them until each is registered under its own argued disposition. That is the honest boundary, not a regression, and it is the next treasury follow-up: a store with a registered treasury and its cooperative index is clear at the gate; a store in which a budget has been created — create_budget writes idx:budgets:<did>:<budget> beside the principal-free budget:<id> row, and it is the index row that is uncovered — or an audit record has been recorded is not, exactly as before. A bare budget:, rule:, idx:coop: or vlimit: row carries no principal in its key and blocks nothing. A DID-looking coop_id in an idx:coop: key is likewise uncovered and never a treasury spelling. Registry pins hold the descriptor to this shape from both sides — in icn-store against literal sibling prefixes, in icn-ledger against the ledger's own prefix constants — so the two cannot drift apart silently.

Division of labour, extending §10.6:

startup gate        — can the node safely open this persisted namespace?
                      keys only; alias pair → refuse; unreadable spelling → refuse;
                      sibling rows outside the descriptor; never a survivor
TreasuryManager     — can this loader safely adopt these exact rows?
                      keys and values; the same alias unit; key/body bytes; institutional
                      duplicates; refuses before any map mutation; never a survivor
idx:coop validation — does the persisted projection agree with primary physical evidence?
                      value must decode and equal the primary row's key bytes; no authority

Where the two layers differ they differ in the direction §10.6 already allows — the loader is the stricter: a spelling that decodes but is no Ed25519 point is readable to the gate and UnreadableKey to the loader; a key beneath the parent with no did:icn: in it at all is a row without a principal to the gate and UnreadableKey to the loader. Neither layer asserts a merge rule and neither normalizes a spelling, so they cannot disagree about a survivor.

Reach. TreasuryManager::with_store is constructed in production by apps/ledger-app init (inside icnd, behind the gate) and by the icnctl treasury maintenance commands (treasury_entity_backfill_report and its apply path), which open the ledger store with no gate in front of them. Both reach the guarded loader; icnctl's gate policy is unchanged and is a separate maintainer decision.

Non-goals. No merge rule — Sum, Union, Max, Latest, canonical-wins and every equivalent stay unauthorized, and RuleBasis::Established here records only that fail-closed is implemented; no re-key, no normalization, no deletion, no repair of observed rows; no change to the audit, budget, rule, labor-share, bond or allocation loaders beyond naming their prefixes; no icnctl gate policy change; nothing of M2–M4 or §7.5.

Mutation evidence — each mutation applied alone to the committed tree, the focused suites run, and both files restored byte-exactly (sha256-checked); every other test in the run stayed green. Observed, not hypothetical:

Mutation Applied Observed failures
A — bypass the principal-row alias guard the refuse_unless_one_spelling_per_principal call removed 5 loader fixtures (the alias pair, the pair under two coop ids, three spellings, every insertion/scan order, scanner-and-loader agreement) and the in-module no-partial-adoption pin; 27 + 69 others pass
B — compare key and body as Did (principal equality) Did::from_str(key).is_ok_and(|k| k != body) in place of the byte comparison exactly one: the key-f/body-z fixture; its control and every other test (31 + 70) pass
C — skip idx:coop spelling integrity the index scan replaced by an empty vector 5 loader fixtures (alias value, alias under another coop id, different registered treasury, undecodable value, write order) and the two in-module index pins
D — scanner disposition FailClosed → Equivalent the treasury descriptor's disposition only the scanner's descriptor pin and alias-blocking fixture; the gate's alias-pair fixture; the loader-side agreement fixture and the in-module registry pin
E — descriptor prefix widened to ledger:treasury: the prefix only three scanner fixtures (siblings outside, DID-looking coop id, descriptor pin); four gate fixtures (single-row clear-and-covered, DID-looking coop id, siblings not misread, spelling-bearing siblings uncovered); two loader-side fixtures and the in-module pin

Evidence: icn/crates/icn-ledger/tests/treasury_principal_rows.rs — 32 fixtures on real sled stores, each refusal with its one-fact-different control; six in-module tests in treasury.rs (the no-partial-adoption pin against the private maps, the key classifier, the sibling list, the registry agreement pin, and the payload-free diagnostics); eight scanner fixtures and the registry pins in did_collision_scan.rs; seven startup-gate fixtures (§10.5). Fixture evidence only.


5. Namespace decisions (Phase 5)

I7 moves Did equality. It does not move any namespace that derives or stores identity in another representation. Each is decided explicitly below; none is left implicit.

Namespace Site Decision Reason
EntityId::from_did icn-entity/src/entity.rs:51 A — follows principal identity An entity derived from a principal must not fork when the principal is re-spelled. Must canonicalize at construction and de-duplicate rows.
StewardId::from_did icn-steward A — follows principal identity Same argument; a steward is a principal in a role.
icn-commons weak-holder id icn-commons/src/inner.rs:357, Sha256::digest(did_str) A — follows principal identity, and is a blocking prerequisite Hashing the spelling means one principal under two spellings mints two holder records. I7 makes the Did-equality gate treat them as one principal while the derived id still says two — a split I7 actively creates. Must be resolved before the equality flip.
Kernel Did = String alias N2-H (#2629) B — remains representation-sensitive for now Out of N2-A scope by the tranche contract; N2-H owns it. Recorded so the mixed domain is not accidental.
Community.members icn-community/src/types.rs:6, MemberId = String ("Can be DID or CooperativeId") B — remains representation-sensitive, and is §7.5-adjacent The type deliberately holds two identifier domains. Principalizing it is a membership change, which §7.5 gates. Must not be smuggled into N2-A.
ReplicaMetadata icn-store B — remains representation-sensitive Replica placement is a storage-locality concern, not a principal-identity one. Documented rather than changed.
SenderPrincipal (icn-net) replay_guard.rs:78 B — intentionally key-based, not byte-based Keys on VerifyingKey because the replay guard and the signature check must agree. It is narrower than Did::identifier_bytes (anchor DIDs may not decompress). Divergence is deliberate and must be documented, not "fixed".

Decision A namespaces gated the equality flip when this was written. The flip has since landed (#2686) without them, so the split each names now exists rather than being anticipated, and they gate the cutover instead. Decision B namespaces gate neither, but each is now a stated choice rather than an omission.


6. Partner invariants (re-verified against 83682563)

These had to change with or before the Eq/Hash flip. As of 2026-09-03: §6.1 moved with the flip (#2686), §6.2 moved before it (#2681), and §6.3 has not moved and now gates the cutover. The designs below are kept as written.

6.1 PeerId ordering — icn/crates/icn-net/src/topology.rs:51

impl Ord for PeerId {
    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
        self.0.to_string().cmp(&other.0.to_string())   // spelling
    }
}

PeerId(pub Did) derives PartialEq/Eq/Hash. Post-I7 equality becomes byte-based while ordering stays spelling-based, so two PeerIds can be == yet cmp to Ordering::Less — breaking the standard-library requirement that Ord be consistent with Eq, and letting a BTreeMap<PeerId, _> hold two entries that compare equal.

Design: order by decoded identifier bytes, with a total, deterministic tie-break for spellings that do not decode (they must still order, and must not order equal to a decodable peer). Landable before the flip; doing so shrinks the atomic surface.

6.2 CCL Value::Did — icn/crates/icn-ccl/src/types.rs:110 and :218

Value derives PartialEq/Eq (so Value::Did equality follows Did), but Hash is hand-written and hashes format!("{did:?}") — the Debug of Did(String), i.e. the spelling.

Post-I7 two Value::Did values can be equal with different hashes. That is not a behaviour difference; it is a violated Hash/Eq contract, and it is silent. HashSet<Value> backs Value::Set, participants and in checks, so membership tests would begin to return wrong answers with no error anywhere.

Design: hash Value::Did over identifier_bytes, falling back to the spelling only where the DID does not decode (so equal values hash equally in both populations). The same applies to the Value::Set hash at :229, which sorts members by format!("{a:?}") — also spelling-derived. This is the sharpest of the three and must be atomic with the flip.

Required regression tests: a HashSet<Value> and a HashMap<Value, _> holding two alternate representations of one principal must observe exactly one member.

6.3 Networking peer maps

  • SessionManager.connections: HashMap<String, quinn::Connection> — icn-net/src/session.rs:58, keyed by the remote peer DID spelling.
  • NetworkActor.peer_connections: HashMap<Did, PeerConnectionInfo> — icn-net/src/actor/mod.rs:178.

Both hold remote-peer connection state: the same identity domain, two keying regimes. Post-I7 the Did-keyed map becomes principal-keyed while the String-keyed map stays spelling-keyed, and they disagree.

Design: converge on one principal identity. Per the #2641 lesson, converting a single lookup call is not sufficient — the audit must cover cardinality (.len() used as a peer count), iteration, deletion, replacement and lifecycle (insert on connect, remove on disconnect), because a disconnect that removes one spelling's entry while the other map keeps its principal entry leaks connection state in exactly the direction that is hard to observe.

6.4 String-versus-Did comparison classification

Site Classification
icn-gateway/src/api/commons/mod.rs:242,278,358 — claims.sub != did.to_string() should become principal-aware — an authorization comparison that a re-spelling defeats
icn-trust/src/lib.rs:1180 should become principal-aware — trust identity
icn-gossip/.../storage_challenge.rs:58 should become principal-aware — challenge attribution
icn-compute/src/receipt.rs:691 should become principal-aware — receipt attribution
icn-store pos, icnctl display/CLI paths intentionally representation-sensitive — echoing what the operator typed
kernel Did = String alias sites belongs to another tranche — N2-H (#2629)

Not mass-rewritten: each needs its semantic classification confirmed at the site before change.

6.5 Loader findings the ledger pass did not fix

The §4.1 audit was run across the other principal-keyed loaders at the same time. These are findings, not fixes — no code below has been changed, and each is stated so the next slice starts from evidence rather than from a re-derivation.

icn-federation AttestationStore — closed by #2704. This finding is recorded as history because the paragraph above says these are findings, not fixes, and this one is now the exception. The defect was four things at once: federation/attestations/<did-spelling>/<coop> appeared in no row of §4 and in no descriptor of n2a_keyspaces, so the scanner could not report a collision there and §3.2's "zero collisions" never covered it; the in-memory cache was LruCache<Did, _> and therefore principal-keyed while member_prefix scanned did.as_str() and was therefore spelling-keyed, so looking up spelling A then spelling B of one principal returned A's attestations for B and the opposite order returned the opposite answer; remove_attestation deleted one spelling's row, so a revocation naming the other spelling left the attestation live; and every read path dropped an undecodable row with if let Ok(..), turning a corrupt attestation into an absent one. None of it needed a migration to appear.

The domain answer was never in doubt — an attestation is a claim about a principal — so the fix is a principal-consistent read, not a re-keying. Since #2704 the store reads the whole namespace, validates that every row's key is exactly the key its own value implies, groups by Did equality, and refuses any operation that would interpret or mutate an ambiguous (principal, source_coop) pair; the cache is gone rather than re-keyed; an unreadable or key/value-inconsistent row is a typed refusal instead of an absence; and a revocation names the semantic pair, removing every spelling of it in one atomic deletion (Store::delete_atomic), so no crash or I/O failure can leave one alias row behind as an unambiguous — and therefore acceptable — survivor. The keyspace is registered as icn-federation/attestations (§4 row 19) and fail closed: it authorizes no merge rule, and neither does this document.

Registration is also what puts the keyspace in front of the gate. Before it, a principal-bearing row under an unregistered prefix was uncovered to the gate, which refuses rather than classify (§10.2) — blocking, but with nothing to say about what it found. The gate now reads this keyspace through the same descriptor the store's own refusals agree with, so both layers use one collision unit: (member principal, exact source_coop_id bytes). The source is a federation-domain identifier this crate does not own and does not parse, so two source ids are the same source only when their bytes are equal — including when both happen to contain spellings of one DID. §10.6's division of labour is unchanged by any of this: the gate answers whether the node may open the state, the store answers whether an operation may interpret it, and neither answer is a merge rule.

icn-security MisbehaviorDetector (#2676) — the survivor is attacker-selectable. Four spelling-keyed keyspaces (security:{reputation,banned,quarantine,violation}:) load independently into four principal-keyed HashMaps, so a principal can end up with its reputation from one spelling and its ban timestamp from another. The winner in each is the byte-greatest key, i.e. the multibase base character, which the writer chooses. save_to_store writes only surviving entries and deletes nothing, so the losing rows persist and can win again on the next start. Since #2700 the startup gate refuses to start icnd over a collision or an unreadable row in security:* (DeferredCollisionPosture::BlockStartup, §10.2) precisely because this loader folds; the loader itself is unchanged, and the fold remains reachable wherever the detector loads with no gate in front of it. The sharpest detail is not in the loader at all: handle_signed already derives a canonical SenderPrincipal for its replay and capability decisions, then passes the raw wire spelling to record_violation — the replay guard and the detector disagree about who the sender is. Security identity should resolve through the decoded principal at the recording site, which makes the loader's job smaller rather than larger.

icn-net peer maps (blocker 7) — and a snapshot restore whose winner is hash order. §6.3's design is confirmed against live code, with two additions. First, nothing removes an entry from either map on transport error or close, so a peer that reconnects under a second spelling leaves SessionManager.connections (String-keyed) holding two entries — one dead, one live — while NetworkActor.peer_connections (Did-keyed) holds one; connections_active then over-reports, peer_exchange advertises both spellings to the federation as distinct peers, and a send naming the first spelling selects the dead connection. Second, and not previously recorded: snapshot restore (actor/mod.rs) parses each stored String key back into a Did and inserts, so two spellings collapse with HashMap iteration order deciding which x25519/ML-KEM key material and capability set the node comes up with — nondeterministic across restarts. Row 15 already says peer_connections has no authorized merge rule; restore is currently implementing one by accident. This remains the larger change (~20 call sites plus a restore policy) and stays its own slice.

  • FreezeManager::load_from_store (pre-existing, unchanged by this tranche): expired freeze rows are dropped at load rather than adopted, exactly as the loader did before I7, so a row that expired before a start is never deleted from disk by cleanup_expired and accumulates. It is not state and it is not a collision, so it blocks nothing; deleting it at start-up would add a writer to the load path, which this tranche deliberately does not do. Follow-up: #2683.

7. Site taxonomy carried in from #2641

#2641 produced six defects across four review rounds; only two were equality comparisons. Any N2-A audit restricted to Did == Did would have missed the other four. Every site N2-A touches is therefore classified across nine axes: equality/comparison; hash/map/set keying; persisted key representation; cardinality/counting; iteration-order semantics; paired computations/joins; collision/migration behaviour; twin implementations; guard reachability.

Three questions are asked explicitly at each site:

  • Partial principalization — if one half of a computation becomes principal-aware, does its counterpart still count, filter, order or join by spelling? (#2641: a .len() quorum denominator over a DID-keyed collection whose numerator had become principal-reduced.)
  • Twins — does the same rule exist separately in actor, manager, library, persisted store, query/read model and restore/snapshot paths? (#2641 fixed one side of a pair twice in a row.) Prefer a shared primitive over per-site fixes.
  • Guard reachability — can an earlier filter, map lookup, de-duplication, early continue, first-match lookup or restore/rebuild step discard contradictory alias evidence before a fail-closed guard observes it? Having a conflict check is not enough if the bad input cannot reach it.

The §6.3 peer-map work and the §5 weak-holder decision are both twin-shaped, and the replay_sender_regime fail-closed path is a guard-reachability case that #2644 already got right.


8. Migration sequence

Status 2026-09-03. Steps 6 and 7 of the sequence as originally planned — the flip and its discriminating tests — have landed (#2681, #2686). The planned order is kept in §8.1 as the historical record; the sequence to follow now is the post-flip cutover order in §8.2. Nothing in §8.1 is an instruction any more.

8.1 As planned before the flip (historical)

Unchanged in structure from the prior pass; step 4's content is narrowed by §1.1 (replay rows already carry their rule in-tree) and widened by §5 (the weak-holder id is a blocking prerequisite, not a follow-up).

  1. Run the collision scan read-only on every live deployment. Blocker for everything below.
  2. Settle the namespace decisions — §5 decision-A namespaces, above all the icn-commons weak-holder id, whose split I7 actively creates.
  3. Fix the partner invariants that can land independently — PeerId Ord (§6.1), the String/Did peer-map pair (§6.3), and the dormant CompressedVectorClock derive shape. These reduce the atomic surface.
  4. De-duplicate durable collision-bearing rows before the first start of a key-equality binary: that first start performs the lossy rebuild and its write-back orphans the losers. Choose the class-C merge rules (§4 rows 14–15) before decode collapses them.
  5. Validate rollback/compatibility. Equality-over-bytes moves no durable byte and changes no acceptance, so a binary rolled back to string equality reads the de-duplicated rows unchanged.
  6. Flip Did Eq/Hash to decoded identifier bytes, atomically with the CCL Value::Did hash correction (§6.2). Done: #2681 (CCL hash), #2686 (flip, PeerId ordering).
  7. Run broad discriminating tests, including the mutation check that the new tests fail under the old equality. Done for the flip in #2686; each loader guard carries its own (§4.1).
  8. Membership and vote migration stay behind §7.5 and are not part of N2-A.

8.2 The remaining cutover order (live)

  1. Re-run the collision scan read-only on every live deployment — the two not yet scanned (§3.1) first — and again immediately before the cutover (§3.5). The scan gates the cutover; it did not gate the flip, which moved no persisted byte. The same audit now runs unconditionally at every icnd start (§10), which makes §3.5's point-in-time limit a limit on evidence gathered outside the binary.
  2. Settle the §5 decision-A namespaces, above all the icn-commons weak-holder id, whose split now exists rather than being anticipated.
  3. Close the remaining partner invariant — the String/Did peer-map pair (§6.3). PeerId ordering and the CCL Value::Did hash are done.
  4. Guard or de-duplicate before a deployment's first start on a build that still folds. The three icn-ledger loaders now refuse rather than fold (§4.1), so for them this step is the refusal itself, and the startup gate (§10) refuses the icnd start in front of every loader over an unruled collision, an unreadable or uncovered principal row and a security:* collision. The loaders in §6.5 still fold; what the gate does not stand in front of — a report-only deferral (§10.2), a database outside icnd's data directory, a loader reached without icnd — is exactly where, for each of them, either the guard lands first or that deployment's collision-bearing rows are de-duplicated before its first start on a build carrying I7. Choose the class-C merge rules (§4 rows 14–15) before decode collapses them.
  5. Rollback/compatibility holds as stated: equality-over-bytes moved no durable byte, so a binary rolled back to string equality reads the same rows.
  6. Membership and vote migration stay behind §7.5 and are not part of N2-A.

9. Remaining cutover blockers

The equality flip (§8.1 step 6) has landed (#2686). The cutover — the point at which persisted spelling-keyed state is proven safe under principal equality and the in-binary refusals (§10, §4.1) can be trusted to hold — may be declared only when all of the following hold, re-stated against main at ceec4820 (the startup gate) plus the ledger loaders. Until 2026-09-03 this section gated the flip itself.

# Blocker State Evidence
1 Collision scans run against live deployment data PARTIAL 3 of 5 deployments scanned, 94 sled DBs, 24 registered rows, 0 collisions. alpha and icn-daemon unscanned (CrashLoopBackOff); sample is small and point-in-time (§3.5)
2 Every observed collision group has an authorized disposition CLEARED (vacuously) zero collision groups observed. Vacuous truth — it does not validate any merge rule
3 Every required keyspace migration has a safe sequence PARTIAL with zero collisions, step 4 is empty for the scanned deployments. The load/rebuild/write-back audit (§8.1 step 4) is done for the three icn-ledger keyspaces, whose loaders now refuse rather than collapse (§4.1); icn-federation's AttestationStore is audited to the same standard and now refuses rather than fold (§6.5, #2704); the icn-federation agreement party index is proven a derived projection and its store answers from canonical membership (§11.3, #2707); the icn-ledger treasury loader classifies its primary rows, checks its cooperative index and refuses before adopting (§4.2, #2627 M1); the remaining §6.5 loaders — icn-security's misbehaviour detector and the icn-net peer maps — have not been
4 Namespace splits created by principal equality resolved OPEN icn-commons weak-holder id decision stated (§5) but unimplemented; #2627 correction 2 records that I7 opens a lower-privilege route to it
5 PeerId ordering DONE Ord over identifier bytes, non-interleaving classes, landed with the flip in #2686 (icn-net/src/topology.rs); #2684 had added the peerid_i7_ordering_tripwire that pins it
6 CCL Value::Did Hash/Eq DONE hash over identifier bytes in #2681, before the flip; #2686 pins the contract in value_did_hash
7 String/Did peer-map semantics OPEN design complete (§6.3), unimplemented — SessionManager.connections is still keyed by the peer spelling (icn-net/src/session.rs)
8 No §7.5 migration smuggled in HELD gov:vote: rows and the icn-coop membership row are excluded, not migrated; the startup gate reports vote collisions and does not act on them (§10.2)
9 Broad discriminating tests for the flip DONE for the flip itself #2686 — did_principal_equality, fifteen tests flipped and three re-scoped (#2627 records the count), the PeerId tripwire; the ledger loaders carry the §4.1 fixtures. What remains untested is what remains unimplemented (rows 4 and 7)
10 Fail-closed check inside the key-equality binary DONE §10 — icnd refuses to start over an unruled collision, uncovered row, unreadable row, unverifiable store or newer-generation receipt; 49 fixture tests plus the scanner's. Inside icn-ledger, the three loaders refuse again for their own keyspaces (§4.1; 30 fixtures), and the treasury loader for its primary rows and cooperative index (§4.2; 32 fixtures)
11 Persisted principal-identity generation boundary DONE (generation 1) §10.3 — the receipt records the generation; a newer generation's receipt is refused. Generation 2 (any re-key) is not designed; the ledger loaders re-key nothing and leave it at 1

Blockers 3, 4 and 7 are independent of collision evidence and would each remain even if every deployment scanned clean; rows 5, 6 and 9 are closed, and row 3 is done for its ledger half. Rows 4 and 7, then the rest of row 3, are the shortest path forward. Row 10 changes their consequence rather than their status: until they are done, a store that trips one of them refuses to start instead of merging silently — and for the three ledger keyspaces the loader refuses again (§4.1), with or without the gate in front of it.

The evidence gate (#1) has moved from no evidence to no collisions in three deployments, which is real but bounded, and §3.5 explains why a point-in-time clean scan cannot license anything on its own. Row 10 is what makes that acceptable: the binding check runs at the moment it matters.


10. The startup gate (implemented 2026-09-02)

icn_store::n2a_startup_gate::enforce runs in icnd after the data directory exists and before the first store is opened — the ledger, trust and parameter stores the daemon builds in main, then everything the supervisor opens. It is the fail-closed check §3.5 and inventory §12.1 item 7 call for, placed where those documents say it belongs.

10.1 What it does, in order

  1. Reads <data_dir>/n2a-startup-gate.json if present. A receipt it cannot parse, or one whose generation exceeds the binary's, refuses — and is left untouched.

  2. Finds every sled database beneath the data directory by its conf file (find_sled_roots): one per domain under store/, plus the data-directory-level databases a deployment keeps (commons.sled, …). A directory that is not a database is never opened, because sled::open would create one. A database added after this document was written is found the same way — there is no list to fall out of date.

    The sweep is all-or-nothing. An unreadable directory, an unreadable entry, a symlink, or the depth bound each refuse rather than returning a shorter list. Review found the original walk swallowed all four: a directory that is searchable but not readable — the daemon can still open …/store/ledger by path while being unable to enumerate …/store — made an existing database look absent, and the gate would then have written a CLEAR receipt over a store it never audited. A caller cannot distinguish a partial list from a complete one, so the function must not produce one. A symlink is refused rather than followed or skipped: following lets the sweep leave the intended subtree, and skipping omits a database the daemon can still reach through it.

  3. Opens each database, runs audit_sled_store — the same computation did-collision-scan renders — and closes it so the daemon's own open can take the lock.

  4. Writes the receipt atomically (sibling temporary, fsync, rename) once a verdict exists — for clear and for refused. The refusals that occur before any store is audited (an unreadable or newer-generation receipt, a missing data directory, an incomplete sweep, an unverifiable store) return without recording anything: there is no verdict yet to record, and overwriting a prior receipt would destroy the last one that meant something.

  5. Returns the receipt on clear; otherwise a GateRefusal that icnd turns into a non-zero exit with the payload-free summary in §10.4.

It never writes to a domain store. The only mutation while it runs is sled's own recovery on open, which the daemon would perform moments later regardless.

10.2 What refuses, and what only reports

Condition Effect Why
Collision in a registered keyspace whose rule is Established and automatable (replay_max_seq, replay_finalized, journal, agreement_party_index) clear, group recorded the live loader already implements the merge (§1.1, §4) — or, for the party index, proves membership from the canonical row on every read, so the group is a redundancy and not a decision (§11.3)
Collision in a registered keyspace whose rule is AwaitingDomainSignOff (§2.4's seven, including sequences_receiver) refuse a plausible rule is not an authorized one, and a rule written down by precedent is not one a loader performs
Collision in a FailClosed keyspace (replay_sender_regime, icn-coop/member) refuse no rule exists
Unreadable principal row in a registered keyspace refuse cannot be classified, so cannot be merged on its own recognizance
Principal-bearing row under no registered keyspace and no named gate refuse, masked shape reported a keyspace nobody classified is the one that collapses unexamined
Principal-bearing row in a named tree refuse Store::scan cannot examine it
Collision or unreadable row in security:* refuse DeferredCollisionPosture::BlockStartup — MisbehaviorDetector::load_from_store folds alias rows into principal-keyed maps and save_to_store at shutdown orphans the losers (#2676)
Collision in gov:vote:* report only ReportOnly — votes are read per proposal, tallied through VoteTally::try_from_votes which fails closed on conflicting rows (#2641/#2677), and nothing at startup writes vote rows back. Deciding this is §7.5's business
Collision in auth:challenge:* report only ReportOnly — a TTL-bounded nonce; collapse drops an in-flight challenge the client re-requests, nothing is written back, and blocking would trap the daemon that alone expires the rows
A store that cannot be opened (held elsewhere, corrupt) or read completely refuse a store nothing can be said about is not one to start over

The postures are recorded on each DeferredNamespace with a one-line rationale citing the loader behaviour they rest on, and a test pins that every deferral carries one. They are statements about the load paths in this checkout; a loader that changes must revisit its posture.

10.3 The receipt and the generation

n2a-startup-gate.json is a record, not a skip token. The audit runs at every start whatever the receipt says; a test inserts an alias row after a clear receipt and proves the next start refuses. The receipt exists so an operator can see what was inspected, which keyspace refused and why, and when — and so a later generation has a boundary this one detects.

PRINCIPAL_IDENTITY_GENERATION = 1 means I7: principal-byte equality, spelling-preserving persistence, no row re-keyed. A receipt carrying a higher generation was written by a binary that may have re-keyed rows; a generation-1 binary refuses to open that data directory rather than guess, and does not overwrite the receipt. A lower or absent generation simply means the audit has not been recorded under generation 1 yet.

Rollback. A pre-gate binary knows nothing of the receipt and ignores it; I7 moved no byte, so it reads the same rows. A generation-1 binary rolled forward again re-audits. What no binary can retroactively enforce is the interval in which a pre-gate binary ran over a store that has since acquired alias rows — which is exactly why the audit is unconditional.

Crash safety. An interrupted receipt write leaves only n2a-startup-gate.json.tmp, which is never read as a receipt; the next start audits and replaces it.

10.4 What an operator does with a refusal

The daemon exits non-zero with one line per blocker, of the form

<store>: keyspace icn-ledger/balance (sum, awaiting-domain-sign-off): 1 collision group(s) over 2 row(s), 0 unreadable; principals [1a2b3c4d]

carrying the keyspace, the rule and its authority status, counts, and eight-hex principal fingerprints — never a stored value and never a full identifier. Then:

  1. run did-collision-scan <store> for the full report, including which spelling a last-writer rebuild would keep;
  2. take the disposition to the domain that owns the keyspace (§4): economics for the ledger sums and freezes, trust for the edge union and issuer sequence, governance for membership, the security workflow for security:*;
  3. apply that disposition to the rows by hand, or flip the descriptor's RuleBasis to Established with the approval cited, and restart.

There is no bypass flag, by design: the alternative to refusing is the silent merge the whole tranche exists to prevent.

10.5 Evidence

icn/crates/icn-store/tests/n2a_startup_gate.rs — 49 fixtures on real sled databases (32 with #2700, five more for the attestation keyspace with #2704, five for the agreement party index with #2707, §11.3, seven for the treasury primary rows with #2627 M1, §4.2): every row of §10.2 with its one-fact-different control (same spelling twice, two different principals, a single security row, an uncovered row without a principal); the fixture guard that the two spellings are distinct strings and == as Did with equal hashes; unreadable and newer-generation receipts refused and preserved; older-generation receipt superseded; the record-not-token property in both directions; idempotence with every store's rows byte-identical across runs; a stale .tmp not read; no database created where none existed; a store held open elsewhere unverifiable and nothing recorded; the receipt and the refusal message carrying neither payload nor identifier. icn/crates/icn-store/src/did_collision_scan.rs adds eight tests on the deferred postures and the shared audit. All fixture evidence; no deployment was scanned by the gate.

10.6 The gate and the ledger loaders (#2701)

The startup gate and the icn-ledger loaders of §4.1 are two enforcement layers, not one check in two places, and the cutover relies on both.

The gate asks whether this node may safely open all persisted principal-bearing state beneath its data directory. It runs in icnd before the first store is opened, reads keys and never values, knows a keyspace only by its registered prefix (§2.7), and refuses or clears the whole directory. The loaders ask whether this keyspace's rows can be interpreted and adopted by the code that owns them. They run inside Ledger::new — in icnd after the gate has cleared, and in every other embedder and test with no gate in front of them — know the exact shape their writer produces, and read a value only after its key has been accepted.

For icn-ledger/balance, icn-ledger/cleared_volume and icn-ledger/frozen the two layers agree wherever they observe the same thing, and differ only where they do not:

Question Startup gate (§10) Ledger loader (§4.1)
Collision unit the raw key with each did:icn: spelling replaced by its decoded 32 identifier bytes (§2.7); the currency stays in a cleared_volume shape, so only same-currency rows group the decoded identifier bytes plus the discriminator — the currency for cleared_volume, empty otherwise — through icn_ledger::principal_rows: the same unit
Merge rule none asserted; a collision under AwaitingDomainSignOff refuses (§10.2) none asserted; a collision is PrincipalRowsRefusal::AliasCollision. No sum, union, latest-wins, normalization, re-key or de-duplication
Readable spelling one the layout-independent tokenizer can delimit and decode to 32 identifier bytes (§2.6); any other did:icn: token is an unreadable row and refuses the writer's grammar plus Did::from_str, which also requires an Ed25519 point. Within the alphabets the tokenizer walks the loader is the stricter: a spelling that decodes but is no principal is UnreadableKey here and readable there. A spelling the tokenizer cannot delimit at all — an Identity-base body, whose sigil and raw bytes fall outside those alphabets — is unreadable to the gate and parses in the loader (§4.1's Identity fixture)
Physical shape prefix match plus the spelling; did_ends_key for frozen only. A key under ledger:balance: with no did:icn: in it is a row without a principal and does not block the writer's exact shape: canonical JSON quoting for balance (a bare or non-canonically escaped key is UnreadableKey), a currency delimiter after the spelling for cleared_volume, strict UTF-8 for frozen; anything else refuses
Values never read read only after the key is accepted; a balance or frozen body whose spelling disagrees with its own key is KeyValueSpellingMismatch
Expired freeze rows not distinguishable without reading the value, so two spellings of one principal under ledger:frozen: are a collision whatever their expiry dropped before grouping: an expired row is not live state, so a lapsed alias does not block a start
Write-back none — the gate writes to no domain store keeps the stored row identity (HashMap::get_key_value, remove_entry); no spelling is normalized and no row is re-keyed, so PRINCIPAL_IDENTITY_GENERATION stays 1
Diagnostic store, keyspace, rule and its authority, counts, four-byte (eight-hex) principal fingerprints (§10.4) keyspace, counts, eight-byte principal fingerprints, and for cleared_volume an escaped and bounded currency discriminator; never a spelling, never a value

Three consequences follow, each deliberate:

  • Gate CLEAR does not mean the loader has nothing left to check. A row the gate cannot see as a problem — a bare ledger:balance: spelling, a non-canonical JSON escape, a spelling that decodes but is no Ed25519 point, a cleared_volume key with no currency delimiter, a body that spells its account differently from its key — is refused by the loader. The gate is complete over principals across the directory; the loader is complete over this keyspace's grammar.
  • A loader refusal never contradicts the gate's disposition. The loader's collision unit is the gate's, and every token the gate can delimit but not decode fails Did::from_str too, so a row the gate refuses as a collision or as an undecodable spelling is one the loader refuses as well — with the two exceptions below, where the gate refuses and the loader does not, never the reverse. Neither layer asserts a merge rule and neither normalizes a spelling away, so they cannot disagree about a survivor: there is none.
  • Where the gate is the stricter layer, that is a boundary, not a conflict. Two cases. An expired alias freeze is a collision to the gate, which must not read the value that would show it lapsed, and is not state to the loader. A spelling the tokenizer cannot delimit is unreadable to the gate and a legal account to the loader — a limit of the scan's tokenizer (§2.6), not of the keyspace. In icnd the gate runs first, so in both cases the node does not start and the loader's answer is never reached; it is observable only where Ledger::new runs with no gate in front of it. Neither case involves a merge rule: the operator's remedy (§10.4) is to remove a lapsed row the loader would not have adopted, or to take the tokenizer limit to the scanner.

Neither layer replaces the other. The gate covers every principal-bearing keyspace in the directory — the loaders that still fold (§6.5) and any keyspace nobody registered included — and the loaders cover exactly four keyspaces — the three above and, since M1, the treasury primary rows (§4.2) — with knowledge the gate must not have. Without the gate, §6.5's loaders run unguarded; without the loaders, every opener of a ledger store that is not icnd, and every row the gate cannot see, is unguarded.

Evidence: icn/crates/icn-ledger/tests/principal_keyed_rebuild.rs — 30 fixtures on real sled stores, one per row of the table above and its one-fact-different control (§4.1), plus the principal_rows unit tests; icn/crates/icn-ledger/tests/treasury_principal_rows.rs for the treasury loader (§4.2); icn/crates/icn-store/tests/n2a_startup_gate.rs for the gate (§10.5). Fixture evidence only.


10.7 Who runs the gate — operator tooling coverage (#2627 M4d)

§10.1 describes what the gate does once something calls it. M4d asked the separate question: can any production- or operator-capable path reach a governed store without it?

The opener inventory. n2a_startup_gate::enforce had exactly one non-test caller, icn/bins/icnd/src/main.rs. The workspace has nine binary targets. Classified with operational evidence rather than by name:

Binary Class Opens governed state Gate before M4d
icnd production yes yes
icnctl operator yes — 9 SledStore::open sites in 7 functions no
icn-console network client no — single file, zero icn_* use; its icn-store/icn-trust deps are unused manifest edges n/a
did-collision-scan scanner / safety tooling yes, deliberately no — and correctly so (below)
commons_restart_helper test-only via CommonsHandle::with_sled_path no
ledger_restart_helper test-only yes no
trust_restart_helper test-only yes no
governance_restart_helper test-only yes no
gossip_restart_helper test-only no sled — snapshot JSON only no

The five *_restart_helper binaries are Layer-4 cross-process persistence fixtures. Each is spawned by exactly one integration test through CARGO_BIN_EXE_*, and for all five every reference in the repository falls into four buckets — the crate manifest, the helper's own source, that one test, and docs/. None appears in a Dockerfile, compose file, K8s manifest, systemd unit, workflow, or shell script. They are nonetheless built by the root Dockerfile's cargo build --release --bins and carry no required-features; that is recorded as debt below, not treated as reachability.

icnd re-proved by call path. build_services contains a sled::open and is declared above the gate call, but is invoked after it, and a refusal propagates out of main. Line order is not call order; the ordering is correct.

The bypass, reproduced (as init-coop stood at M4d; its path was corrected in #2718, which does not change what this evidence showed about gate coverage). icnctl init-coop ran create_dir_all(<data_dir>/store), SledStore::open on it, and TrustGraph::add_edge — writing trust/edges/, a registered keyspace whose basis is AwaitingDomainSignOff, so a collision there fails closed however its disposition reads. On a real sled fixture holding an alias pair, the gate refused the data directory while the same seam opened it and grew the keyspace from two rows to three. TrustGraph::new neither loads nor folds, so no loader guard stood behind it.

What was already safe, and why that is the actual finding. icnctl treasury entity-backfill-apply mutates ledger:treasury: just as ungated — and refuses anyway: TreasuryManager::with_store calls load_from_store → read_persisted_state, the M1 principal_rows classification, which reports "1 persisted row group(s) name one principal under several did:icn: spellings … refusing to rebuild". The cooperative-store commands touch icn-entity keys, which strip the did:icn: scheme and are invisible to a key scanner, so the gate has nothing to say about them either way.

So coverage before M4d was accidental rather than structural: icnctl was safe exactly where an earlier milestone had happened to harden that keyspace's loader, and unsafe where none had. The protection tracked which keyspaces past work reached, not which paths can open state.

Enforcement: entrypoint, at handler granularity. icnctl gained one helper, enforce_n2a_gate, calling the same enforce the daemon calls, at four dispatch points: init-coop, coop maintenance, treasury maintenance, and verify-backup --verify-ledger (whose restored tree is a data directory like any other — a backup whose ledger the gate would refuse is not one the command should call safely restorable). Store-construction enforcement inside SledStore::open was rejected: the gate itself opens stores that way and would recurse, and it would break the scanner. Pasting the call at each of the nine open sites was rejected too — the gate's unit is a directory, not a file.

Three consequences are stated rather than hidden.

The receipt. The gate writes its own n2a-startup-gate.json, so commands documented as read-only now leave that one file — the gate's record of what it inspected, never a domain-store write. The three --help strings that said "Writes nothing" now say what is actually true.

The lock. The gate takes each sled root's exclusive lock while auditing, so a running daemon makes it refuse. That refusal is StoreUnverifiable, not Blocked, and the two must not be reported alike: only Blocked means the directory holds colliding rows, and telling an operator whose real mistake was leaving the daemon up to go hunting for an alias pair is a wrong answer delivered confidently. enforce_n2a_gate branches on the variant, and a fixture pins that a non-collision refusal never claims the collision cause.

The scope of the read. The gate's unit is the directory, so a command that previously opened only <data_dir>/store/cooperative now opens, sled-recovers and row-scans every database beneath the data directory. Two effects follow: an unopenable unrelated store now fails a command that had no interest in it, and runtime becomes proportional to total rows across all stores on every gated invocation. The gate module's own justification — that its opens cost nothing because "the daemon would perform sled recovery moments later anyway" — is true for icnd and does not transfer to a CLI run with the daemon stopped. This is accepted rather than optimised: every one of those stores is one icnd already refuses to start over, so nothing is being rejected that a running deployment could have.

The scanner exemption is named, not accidental. did-collision-scan must reach state the gate refuses — an operator handed a refusal is told to run exactly it for the row-level report. It is not skipping the check: it is the offline form of the same audit_sled_store computation, so the two cannot disagree about what is safe. A fixture pins that it still reports over a store the gate refuses.

Descriptor and inventory: unchanged, deliberately. M4d registers no keyspace, changes no descriptor, no MergeDisposition, no RuleBasis, and no inventory row. It changes who runs the existing gate, not what the gate knows — so n2a_keyspaces(), n2a-a0-stored-key-inventory.md and every §11 disposition are untouched, and no readiness classification moves.

Non-claims and debt.

  • icnctl is not proven exhaustively gated. Four handlers are covered. Other subcommands touch the age keystore, snapshot files and backup archives; those are not sled and not N2-A-governed, and were not audited for other properties.
  • icnd opens <data_dir>/store/trust; icnctl init-coop opens <data_dir>/store itself — fixed in #2718. M4d recorded this rather than fixing it: the wizard wrote its bootstrap trust edges into a sibling sled database the daemon never reads, so it reported edges the node never saw. The repair gave the layout an owner — icn_core::config::trust_store_path — that icnd and icnctl both resolve through, instead of a documented convention plus a literal per caller. Already-provisioned data directories keep their edges stranded in the old sibling database; nothing migrates a row.
  • The restart helpers remain ungated and unrestricted. They are test-only by evidence, but they are ordinary [[bin]] targets with no required-features, so cargo build --bins produces them. The root Dockerfile builds them and copies only icnd/icnctl; deploy/Dockerfile.icnd avoids it with explicit --bin flags. Gating them was rejected as scope; constraining them with required-features is the follow-up.
  • Library-level injection surfaces are unchanged. SledStore::from_db and SledStore::db() can still hand a caller-supplied sled::Db to any store constructor without a path the gate could have discovered. No production entrypoint reaches them ungated today, and closing them would require a typed gated-handle capability — evaluated and deliberately not built here.
  • verify-backup gates only under --verify-ledger. That is still true: plain icnctl verify-backup does not audit the restored tree. What changed is the claim — icn#2717 removed the "This backup can be safely restored" claim from both branches, because the command never establishes it: bare verify-backup now states that ledger contents and the N2-A audit were not verified and that --verify-ledger is how to check them, and the --verify-ledger branch names only what it computed — archive integrity, the N2-A audit of the restored tree, and — since icn#2736 — that every ledger entry is valid under icn_ledger::entry_validation, the owner Ledger::validate_entry itself consults, together with the ledger validations it did not perform (amount signs, hashes, signatures, provenance, parent existence). Amount signs stay named: icn-ledger owns a sign check in entry::validate_positive_amounts, reached by JournalEntryBuilder, which this command does not run, so "valid under icn-ledger's entry validation" must not be read as the crate's union. Before icn#2736 the invariant was claimed only when a balance was actually computed, because a journal whose entries carried no currency deltas satisfied it vacuously and the summary said so instead of claiming it; delegating to the owner made that state unreachable — an entry with no account deltas is now refused rather than passed over — so the remaining no-balance terminal is an empty journal. Freeze state, credit limits and progressive limits stay outside the claim by construction: they are append-time policy evaluated against live ledger state and the current clock, so an offline verifier applying them would reject entries that were valid when appended. The bare branch's checks are unchanged — only its claim narrowed. The --verify-ledger branch did change what it checks, and the bullet below records how: it resolves the canonical ledger path, refuses an absent or partial database before the gate can create one, refuses a database sled REPLACED rather than recovered (icn#2732 — a bounded claim: it is not a whole-database integrity check, and does not establish that every row a backup once held is still readable), decodes rows as JournalEntry, and asks the ledger's own validator whether each one is valid. The recovery refusal has to run before the gate for the same reason the presence refusal does, one layer down: sled::open replaces a database it cannot parse with a fresh empty one, and its was_recovered() signal is false only on the FIRST open — the gate's own open would persist the repair and leave the corruption to be certified as ✓ Ledger empty. Widening what the bare command verifies would change what it means and is still not done here.
  • A pre-existing verify-backup path bug, found while placing that gate. FIXED by icn#2717. verify_ledger_in_backup looked for <restore_dir>/ledger, but backup archives the data directory whole, so the ledger lands at <restore_dir>/store/ledger. The ledger check was therefore a no-op that printed "No ledger database found (may be new node)" for backups that do contain stores — and, because the miss branch returned Ok(()), still reported ✓ BACKUP VERIFICATION PASSED. M4d did not fix it; the gate call added beside it recurses through find_sled_roots and does reach the real stores, but that was a side effect, not a repair. icn#2717 resolved the path through a new icn_core::config::ledger_store_path owner (the third caller to re-derive this layout, after icn#2718), made the miss branch fail closed when --verify-ledger was explicitly requested, and made unparseable journal rows fail rather than be warned about and counted as verified.
  • The identity-base delimiter hazard is untouched. M4c proved an accepted DID spelling can carry : or /; Did::from_str is not changed here, and other delimiter-framed keyspaces remain unaudited.
  • icnctl calls enforce on the runtime thread, where icnd wraps it in spawn_blocking. In a CLI nothing else is scheduled at that point, so it is harmless; the asymmetry is noted so it does not read as an oversight.

11. Persistence-boundary classes — what the common scanner proves, and what it cannot

Three N2-A fixes in a row — the ledger balance fold (#2701), the federation attestation store (#2703/#2704) and the agreement party index (§11.3) — failed by three different mechanisms, and a fix copied from one would have been wrong for the next. Patching one prefix at a time therefore does not converge. This section classifies every persisted principal boundary by the mechanism that proves it safe, so the remaining inventory (§11.4) is finite: each boundary is dispositioned by its class, and a boundary's class can be re-checked from three facts a reviewer can read off the code — how the principal is represented at rest, what the loader does with it, and whether the row is authoritative or derived.

The labels are P1–P5, deliberately not the inventory's A–E. The inventory's letters (§3 of n2-a0-stored-key-inventory.md) describe what I7 moves — ephemeral, reconstructed, serialized, durable key, wire round-trip. The classes below describe how a boundary is proven. One keyspace can carry an inventory letter and several P classes at once, and conflating the two alphabets is how a SILENT-MERGE RISK row came to be treated as though a scanner pass had cleared it.

11.1 The classes

Class Mechanism at rest What proves the boundary safe What the common scanner can say
P1 — spelling-visible key The persisted key retains a did:icn: spelling (as_str, Display, JSON-quoted) A registered descriptor with an authorized disposition (§4), plus a loader that either never folds by Did or folds under a P4 guard. The startup gate (§10, #2700) refuses an unruled alias pair at every start Can group rows by principal and prove no alias rows exist today under the prefix. That is evidence about the rows, not about the loader
P2 — derived / opaque key The key is a function of the spelling the scanner cannot reverse: hash(did.as_str()), EntityId::from_did (scheme-stripped), StewardId::from_did (SHA-256 over the spelling), the weak-holder id Divergence must be prevented before derivation — derive from identifier bytes (§5 decision A) with a de-duplication/migration step for rows already split — or a higher-level loader must fail closed on two derived ids that resolve to one principal. Nothing else is evidence Nothing. These keys hold no did:icn: literal, so the scanner does not count them as principal-bearing at all: they are absent from the covered, deferred and uncovered totals. A CLEAR verdict over a store full of them is silence, not safety
P3 — value-carried principal The key names no principal; the serialized value carries a Did, a Did-keyed map or set, or a spelling String A value-aware audit or the loader's own interpretation of the value under Did equality; the merge rule for a decode that collapses aliasing keys must be chosen before decode (§4 rows 14–15) Nothing — the scanner never reads a value (§2.2, payload-free)
P4 — principal-fold boundary Spelling-keyed rows are loaded into HashMap<Did, _>, HashSet<Did> or Vec<Did>::contains, and the result is written back Classification of every row before any reaches the map, refusing an ambiguous keyspace whole (the principal_rows shape of #2701); a P1 scan is necessary evidence but cannot see the fold Sees the rows, not the fold: it can say two spellings exist, and cannot say that the loader will collapse them, orphan the loser on write-back and make the survivor attacker-selectable (§2.7)
P5 — derived projection / secondary index Rows are a deterministic function of a canonical object stored elsewhere — idx_*, by_did/, *_owner: indexes A source-of-truth declaration; reads that prove the fact from the canonical row rather than from the index; superseded-row cleanup on replacement; deletion of every spelling on delete; a deterministic rebuild from canonical rows; and a rule that malformed projection rows are surfaced, never read around. The projection is never authority Sees the rows. A collision under a projection prefix is a redundancy, not a contradiction, so its disposition is Equivalent once the loader is projection-correct — and only then

A boundary may carry several classes. commons/holders/by_did/ is P1 + P5 over a P2 canonical row; ledger:treasury: is P1 + P3 + P4. The class of the canonical row decides the disposition; projection classes add consistency machinery but never a merge rule of their own.

11.2 "Scanner clear" is not "persistence boundary proven safe"

The two claims must never be written as one. Precisely:

  • Scanner clear means: among rows under registered prefixes whose key contains a decodable did:icn: spelling, no two rows share a principal-canonical shape; every registered keyspace with a collision carries an Established, automatable rule; and every did:icn:-bearing row in the store is either covered or behind a named deferral (§2.3). It is a statement about P1 rows on the day of the scan (§2.5, §3.5).
  • Persistence boundary proven safe means: the boundary's class is stated, the mechanism that class requires is implemented in the live loader/store, and discriminating tests exist for it — including the mutation check that the tests fail with the mechanism removed.

The scanner discharges the P1 evidence obligation and nothing else. It cannot see P2 keys, P3 values or P4 folds, cannot tell a projection from an authoritative row, and cannot speak for a store it did not scan. Any sentence of the form "keyspace X is safe" must therefore name the class and the mechanism; a scan verdict alone is never that sentence.

11.3 Disposition of idx_agreement_party/ — P5, Equivalent / Established

Finding: derived, not authoritative. AgreementStore::store_agreement writes one idx_agreement_party/<party-did spelling>/<agreement id> row per entry of the canonical Agreement.parties vector, valued by the agreement id; nothing else writes under the prefix and the row carries no fact the canonical federation/agreements/<id> row does not already state. Every membership decision in the federation crate — proposer checks, signature admission, gossip is_party_to, suspension, termination, amendment rights — reads agreement.parties under Did equality; none reads the index. The index is therefore a P5 projection and the correct disposition is to prove and maintain it as one, not to treat two index spellings as two competing facts.

Defects verified against main before the fix (all reproduced by tests that failed on the unchanged code):

  1. a lookup under an alternate spelling of a party found nothing — the projection omitted membership canonical state contains;
  2. replacing an agreement whose party set shrank (a ratified RemoveParty amendment, or a gossip sync replacement) never retired the old row — the projection preserved membership canonical state no longer contains, and the lookup returned it;
  3. a well-formed index row naming a non-party returned that agreement — the projection could create membership;
  4. an index row whose value named a different agreement than its key was read by the value;
  5. malformed index rows were silently skipped, and so were malformed canonical rows in list_agreements;
  6. deletion removed only the rows the current party set implied, leaving alias rows behind;
  7. two rows for one (principal, agreement) would have produced the agreement twice once lookups became principal-wide.

Fix (#2707). Persisted encodings are unchanged; no row is re-keyed, merged or normalized. The store now holds one invariant: the party index may accelerate discovery of canonical agreements and may never create, omit, preserve or alter agreement-party membership independently of canonical agreement state.

  • Reads prove membership from canonical rows. list_agreements_for_party reads the whole projection, keeps every row whose spelling decodes to the queried principal (the same decode Did equality uses), de-duplicates by agreement id, loads each canonical row once, and returns only agreements whose parties contain the principal under Did equality. The answer is the same under every spelling, in either insertion order, from a warm or a cold handle, and after reopen; results are ordered by (created_at desc, id) so order is a function of the data.
  • Two kinds of inconsistency are told apart, by whether the write protocol can produce them. A row pointing at a missing agreement, or at one that no longer lists the principal, is stale: the protocol below can leave it behind, so reads filter it and the rebuild removes it. A row the store could never have written — a key that does not parse as idx_agreement_party/<spelling>/<id>, a spelling that names no principal, a value naming a different agreement than the key — is malformed: every operation that interprets the projection refuses with AgreementPartyIndexMalformed { rows, first_reason } (every row is classified before refusing; no spelling or payload travels in the error). Canonical evidence is held to the same standard: an unreadable canonical row refuses every operation that needs it with AgreementStoreUnreadable, including list_agreements, which no longer skips it; and a canonical row that deserializes but carries a different agreement's id than its key names is attributed to neither agreement and refuses with AgreementStoreKeyValueMismatch — such a row is one row's value under another row's key, and using it would let a replacement retire the other agreement's projection rows, a rebuild call the named agreement's real rows stale, or a lookup report a party absent from a row it never read. Every mutating path (store_agreement, delete_agreement, rebuild_party_index) reads canonical evidence before it moves a byte, so both refusals leave the store byte-for-byte unchanged. An agreement id is never empty: store_agreement refuses one before any byte moves (AgreementIdEmpty), and a canonical row that carries one — a shape a pre-#2707 store could hold, and one the gossip handler would otherwise accept from any peer — is unreadable canonical evidence, refused by every operation that needs it including the rebuild, because the projection cannot encode it and a rebuild over it would oscillate (integration review finding, generation 2).
  • Writes keep the projection a superset of the truth, never a subset. store_agreement writes the new party rows, then the canonical row, then retires the rows the previous canonical version implied and the new one does not (a removed party, or a party the new version spells differently). delete_agreement removes the canonical row and then every projection row naming that agreement under any spelling. A crash at any point leaves extra rows, which reads filter, and never a canonical row without its rows. Reads and writes of the namespace are serialized in-process — a process-wide RwLock, the #2704 pattern: writers exclude each other, so two concurrent replacements of one agreement cannot interleave their cleanup and strand a canonical party, and a party lookup holds the namespace across its projection scan and its canonical loads, because Store::scan on sled is not a snapshot (the concurrency note below).
  • The projection can be recomputed. AgreementStore::rebuild_party_index derives the expected rows from every canonical row and makes the projection equal to them, reporting rows kept, added, removed as stale and removed as malformed. It refuses before mutating anything if any canonical row is unreadable. No canonical byte is touched.
  • The scanner registers the prefix as icn-federation/agreement_party_index, Equivalent, Established, under the structural descriptor #2704 made canonical: PrincipalRegion::AnchoredThenOpaque { terminator: b'/' }. The party spelling is anchored immediately after the prefix and ends at the /; the agreement id after it is an opaque discriminator carried into the canonical shape byte-for-byte and never parsed — an identifier the agreement's creator chose, which the registry does not own, so an id that contains or is a did:icn: spelling is still just an id and no id is normalized. The collision unit is (party principal, exact agreement-id bytes). slash_ends_did is claimed by no descriptor: the anchored region ends the spelling by construction, and the registry pins that. A federation-side test pins the registration to the store's own separator; scanner fixtures prove the alias pair is one automatable group, that different agreement ids never group, that an id containing a DID spelling stays an id, and that the rows are covered rather than uncovered; and registry pins fix the anchored layouts as exactly the two federation keyspaces in registry order and the whole-key layouts as the twelve pre-existing ones, unchanged.

Startup gate (added on integration onto #2700). icn/crates/icn-store/tests/n2a_startup_gate.rs carries five party-index fixtures on real sled databases: an alias pair for one agreement is classified as one Equivalent group and the start is clear, with every row byte-identical afterwards; the same two spellings across two agreements are two shapes and no group; a party spelling that names no principal refuses; an agreement id containing a DID spelling does not refuse; and a party-index row is never reported uncovered. The fixtures discriminate on exactly the two facts they exist to pin: with the disposition changed to FailClosed the alias-pair fixture refuses, and with the region changed to WholeKey the alias-pair and opaque-id fixtures turn unreadable. A CLEAR from the gate means this spelling collision is safe under the registered projection disposition. It does not mean the projection is complete, current or authoritative (§11.2).

Rebuild stays an explicit operation. The gate is read-only (§10.1) and the daemon has no post-open repair hook; none is introduced. rebuild_party_index is a domain projection-repair operation invoked deliberately, never a startup step, so no silent migration runs on daemon start. A legacy store whose index was left incomplete by an old torn write is made complete by the rebuild, not by reads.

Concurrency (found on integration onto #2704). #2704 established that Store::scan on sled is not a snapshot: its iterator takes its read lock once per item, so a mutation that commits partway through a namespace read is visible to the rows not yet visited and invisible to the ones already collected. The write protocol above keeps the projection a superset of canonical membership at every instant, but a view assembled half before and half after a commit is a superset of nothing. The one shape that bites is a replacement that only re-spells a party: it writes idx/<new>/X, writes the canonical row, retires idx/<old>/X; a scan that had passed the new row's position before the write and reaches the old row's position after it collects neither, and canonical verification cannot recover a row the scan never saw — the lookup would report the party absent from an agreement it belongs to before, during and after, an answer no valid state gives. The [a] → [b] replacement and the delete have no such hybrid: every candidate is checked against the canonical row, and both the pre- and the post-state answer are valid. The per-store writer mutex the branch originally carried could not close this — it coordinated no reader, and nothing between two handles — so the store now holds a process-wide RwLock in the #2704 pattern: writers exclusive; party lookups shared, and held across the scan and the canonical loads; the rebuild exclusive from its first read to its last write. Deterministic tests in agreement::store model the straddling iterator with a Store double paused at a barrier and read whether the namespace is held from the lock itself, never from timing; the re-spelling fixture fails with the read guard removed. icnd constructs one AgreementStore over a dedicated sled database, so the process-wide lock is equivalent to a per-instance one there and stricter only where several handles share a backend. Production behaviour of the store changed in exactly this respect.

What this does not claim. The Equivalent disposition is correct because the loader is now projection-correct; on a binary without this fix the same scan verdict would have been meaningless, which is §11.2 in one sentence. No live store was scanned or repaired; every claim above is fixture evidence.

11.4 Remaining N2-A boundaries, by class and disposition (classification pass of 2026-09-03)

A bounded adversarial pass over the boundaries the inventory and the #2703 audit had left open, re-verified against main at 5add7a48. Live means a production binary constructs the store; dormant means only library code or tests do. None of these is fixed here; each is dispositioned by mechanism so it can be closed by the proof its class requires. At the time of the pass the scanner registry covered none of the prefixes in this table; adr0014:grant:by_grantee: has since been registered and closed (§11.6), and the rest are unchanged.

P5 projections over a P1 or P3 canonical row — close with the §11.3 mechanism (source-of-truth declaration, canonical-membership reads, superseded-row cleanup, every-spelling delete, rebuild):

Boundary Reach Canonical row Consumer today Notes
apps/governance action_item_by_assignee:<did>:<domain>:<item> closed in M4c, §11.10 live the action-item row (assignee: Option<Did>) principal-aware namespace scan; every candidate proved against the canonical row regressed by I7 at both ends: save decided stale-index removal with existing.assignee != item.assignee, principal equality since I7, so re-saving under another spelling leaked the old row; and the reader prefix-scanned the querying spelling, so /gov/me/work and /gov/digest missed items assigned under an alias. Dispositioned and registered in §11.10: the writer compares projection keys, the reader decodes every spelling and proves the canonical row's own assignee, delete and delete_all retire every spelling, and an explicit rebuild recomputes the projection. Historical alias rows are not reconciled
icn-gateway adr0014:grant:by_grantee: closed in M2, §11.6 and receipt:meeting_attendance:by_pair: live adr0014:grant:<uuid> / receipt:meeting_attendance:rec:<hash> spelling prefix scan; the rebuild write-back adds and never deletes authorization enumeration by grantee missed alias rows, and alias-issued authority survived alias-spelled revocation. The by-grantee half is dispositioned in §11.6 and registered; the meeting-attendance pair index is untouched and remains open
icn-gateway idx_device_owner: and idx_notif_recipient: live device:<token> / notif:<id> String prefix scan; mark_read/delete_notification authorize by raw String compare against the JWT sub fail-closed for the caller (empty inbox), not principal-correct
icn-gateway v1:interest_idx:<listing>:<did> closed in M4b, §11.9 live the v1:interest: row a sled compare-and-swap on the spelling key is the one-interest-per-member rule alias defeated the de-dup. Dispositioned and registered in §11.9: the guard is on the canonical side, the projection is read as corroborating evidence rather than trusted, and the maintenance parser now follows the writer's framing. Historical duplicates are not reconciled
apps/ledger-app idx_owner:, idx_escrow_creator:, idx_escrow_beneficiary:, idx_budget_owner: (constructed by the gateway) live payment:<id> / escrow:<id> / budget:<id> String prefix scan against the raw JWT sub the clearest sub-versus-stored-spelling surface; normalize sub through Did before index construction
icn-commons commons/{anchors,holders,stewards}/by_did/ — the holders third is registered and mint-guarded in M3, §11.7; the anchors third is registered and enrollment-guarded in M4a, §11.8; stewards are untouched (twin: icn-gateway/src/commons_store.rs, dead) live commons/{anchors,holders,stewards}/<id> — P2 rows String lookup; delete_holder removes one spelling; the governance FreezeMember side-effect no-ops on an alias the projection can be made correct, but the canonical id is hashed from the spelling: see the P2 group
icn-ledger asset_owner:<did>:<asset> dormant asset:<id> read re-checks owner_did by Did; transfer_custody authorizes by Did and removes the index by the caller's spelling — an orphan the read re-check then hides the §7 partial principalization pattern, verbatim
icn-ledger obligation_creditor: / obligation_debtor: dormant obligation:<id> prefix scan, no re-check, no delete path
icn-identity personhood/by_did/ (dormant) and commons/by_did/ (dead) — personhood/anchors/<id> / commons/holders/<id> exact-key delete the dead store; rebuild rule for the dormant one
icn-governance index:delegations:from: / index:delegations:to: (dormant, write-only) and steward/by_did/ / steward/by_holder/ (dormant, no persistent backend) — delegation:<id> / steward/records/<hex> none / exact-key delete-dead-code candidates; live twins exist

P2 derived / opaque keys — scanner-blind; close only by canonical derivation (§5 decision A) plus de-duplication, or by a fail-closed loader:

Boundary Reach Derivation Consumer today Disposition
icn-entity entity:<EntityId>, membership:<parent>:<member>, member_count:, and the member_of: / type: projections live (icnd init_entity; gateway api/entity.rs) EntityId::from_did = "entity:icn:individual:" ‖ spelling with did:icn: stripped EntityId string equality throughout; add_membership refuses only the exact key one principal, two EntityIds: duplicate individuals, doubled membership and member_count, a self-removal that misses. Absent from every scanner total. Needs derivation over identifier bytes and a de-dup step — a data-format change, not a merge rule
icn-commons commons/stewards/<hex(StewardId)> live StewardId::from_did = SHA-256("steward:" ‖ spelling) spelling lookup through by_did/; no delete_steward two independent steward records with independent bonds and sanctions; a suspended steward attests under the alias. Fail-closed loader guard now; derivation fix with migration later. Independent defect: the index is written from holder_did and queried by steward_did
icn-commons commons/holders/<hex> (weak-holder path) live; authorization consumer (require_membership_in_jurisdiction, require_office_in_jurisdiction) SHA-256(did.to_string()) as the holder/anchor id spelling lookup the §5 decision-A prerequisite, unchanged: I7 creates this split. A member enrolled under one spelling fails the standing gate under another

P4 folds over P1 rows — close by reusing icn_ledger::principal_rows (#2701, merged): classify every row before the map, refuse ambiguity whole:

Boundary Reach Fold Why the existing guards do not catch it
icn-ledger ledger:treasury:<did> (+ ledger:treasury:idx:coop:<coop> valued by a spelling) live (apps/ledger-app init; icnctl treasury maintenance) was: load_from_store folded every row into HashMap<Did, Treasury> plus Did-keyed budget/rule maps, and persist_treasury wrote back under the survivor's Display; the two hydration guards compared existing_did != treasury.treasury_did, principal equality since I7, so two spellings collapsed at insert with the scan-last row winning closed by M1 (§4.2, #2627), reproduced first: the loader classifies every primary row through principal_rows, proves key/body bytes, validates the index and refuses before any map mutation; registered as icn-ledger/treasury, fail closed, at the gate (§4 row 21). The audit: and idx:budgets: siblings that embed a spelling remain uncovered — the next treasury follow-up
icn-governance bare vote:<proposal>:<voter> and index:votes:<proposal> (P3 value of spellings) dormant (#[allow(dead_code)]; live twin is gov:vote:) store_vote resolves the writer's VotingPrincipal, walks the index and deletes alias rows in one batch correct as a duplicate-act guard, but the prefix vote: does not match the gov:vote: deferral, so any such row is uncovered. Add a second DeferredNamespace under the §7.5 gate, or delete the dormant store

P1 authoritative rows awaiting a disposition — close by registering with the rule the owning domain authorizes (or FailClosed until it does):

Boundary Reach Consumer today Proposed rule Owner
icn-gateway did_doc:<did> live exact key behind a String-keyed LRU; revoke_device mutates only the caller's spelling; no delete FailClosed / Established — merging two key sets decides who may sign identity
icn-ledger membership:since:<did> live write-once-if-absent; an alias re-registers tenure at today FailClosed until credit policy speaks — MaxMonotonic is the wrong rule (max = latest join = tenure loss) and the safe rule (min) is not in the vocabulary ledger credit policy
icn-ledger patronage:account: / patronage:entry: dormant exact key; the entry de-dup is spelling-scoped AwaitingDomainSignOff (plausibly sum / union) cooperative finance
icn-core federation:provenance:<raw coop_did String> service live; durable store wired only in tests String map lookup; leave under one spelling does not delete the other validate coop_did as a Did at the boundary first; FailClosed if registered — two records name two governance decisions federation
icn-trust sybil:verification:<did> and sybil:flag:<did>:<ts>:<type> (P3 values) dormant, absent from the inventory exact key / spelling prefix; clear deletes one spelling delete-dead-code, else FailClosed — Revoked versus Verified under two spellings is contradictory by construction, and a permissive rule would weaken an anti-sybil control trust

Three of these findings are regressions introduced by I7 itself rather than legacy rot: the action-item stale-index condition (closed by M4c, §11.10), the treasury hydration guards (closed by M1, §4.2), and the asset-transfer index removal. Each is the §7 partial principalization pattern — one half of a computation became principal-aware while its partner still counts, deletes or keys by spelling.

11.5 Non-claims, and reconciliation with the merged gate, ledger and attestation work

  • N2-A is not complete. §9 stands. This section makes the remaining work finite; it closes one boundary, and M1 (§4.2) closes a second.
  • No merge rule was invented. Row 20 uses the existing Equivalent disposition because the rows are derivations of one canonical fact; nothing here authorizes a merge of authoritative state anywhere else, and the seven AwaitingDomainSignOff keyspaces are unchanged.
  • Registry scope. At this pass n2a_keyspaces() held fifteen descriptors: the twelve whole-key keyspaces the §3 evidence was gathered with, the two anchored federation layouts (§4 rows 19 and 20), and the treasury primary rows (§4 row 21, #2627 M1) as the thirteenth whole-key layout. Two have been added since — the length-prefixed tag-discriminated grant-by-grantee projection (#2627 M2, §11.6) and the Commons holder-by-DID index (§4 row 22, #2627 M3, §11.7) as the fourteenth whole-key layout — and the Commons anchor-by-DID index (§4 row 66, #2627 M4a, §11.8) as the fifteenth, for eighteen. The listing-interest uniqueness rows (inventory row 26, #2627 M4b, §11.9) have since been added as the sixteenth whole-key layout, for nineteen. The governance action-item assignee projection (inventory row 81, #2627 M4c, §11.10) has since been added as the third anchored layout — and the first whose terminator is : rather than / — for twenty. The §3 figures are not re-stated in any of these cases: a registry expansion does not enlarge old evidence (§3.2).
  • #2700 (startup gate, merged). The gate-level fixtures this section owed are in icn/crates/icn-store/tests/n2a_startup_gate.rs (§11.3). The gate consumes the party-index descriptor read-only; the rebuild is not wired into daemon start, and no post-open repair hook exists or is introduced.
  • #2701 (ledger loaders, merged). Nothing copied; the treasury fold reuses icn_ledger::principal_rows as §11.4 required — done in M1 (§4.2), with one added constant and no change to the guard or its three existing callers.
  • #2704 (attestation store, merged). Both sets of FederationError variants coexist; §4 carries rows 19 and 20; slash_ends_did is claimed by no descriptor — both federation layouts declare anchored regions, and a registry pin fixes them as the two anchored layouts in registry order. The attestation store stays fail-closed (authoritative rows, P1); the party index stays Equivalent (derived projection, P5). They are different classes and are deliberately not made to look alike. #2704's non-snapshot-scan finding is applied here as the namespace lock (§11.3).

11.6 Disposition of adr0014:grant:by_grantee: — P5, Equivalent / Established (#2627 M2)

The authority model. ADR-0014 grant storage is three keyspaces, and only one of them is authority:

adr0014:grant:<uuid>            = the AuthorityGrant. Canonical authority fact.
adr0014:grant:by_decision:...   = derived lookup projection.
adr0014:grant:by_grantee:...    = derived lookup projection.

A Person grantee's semantic key is the decoded Principal — the same relation Did equality itself defines (§11 I7). An Entity grantee's semantic key is the exact entity string under current semantics; nothing here changes that, and the two are not collapsed into one rule.

Defects verified against main @ 10fafafc before the fix, each reproduced by a test that failed on the unchanged code:

  1. a grant issued to Person(A) was invisible to a query for Person(B) when A and B are two accepted spellings of one principal — grant_by_grantee_scan_prefix builds its scan boundary from did.as_str(), so it selects one spelling of a principal that has many. The control under the issuing spelling returned the grant, so the projection omitted authority the canonical record states;
  2. an accepted RevokeAuthority decision naming that principal under the other spelling left the grant active. This is the consequence that matters: the SDIS payloads carry a DID and never a grant id, so revoke_active_grants_for_person can only find its targets by enumeration. Alias-issued authority survived alias-spelled revocation;
  3. the live MandateGate domain-target path, which resolves actor-first through the same enumeration, returned MandateRejection::NoMandate for an actor spelled otherwise than the grant was issued to. This is a false negative — authority that exists is not found — not a privilege escalation;
  4. neither reader checked that the primary record it loaded actually named the grantee asked for. A projection row naming Person(A) but pointing at a grant whose canonical record names a different principal returned that grant to A: the projection could create authority;
  5. a single ordinary Person grant row made icnd refuse to start. The row carries a did:icn: literal, the prefix was in no scanner descriptor and no deferral, and an uncovered principal row is a startup blocker (§10). The gateway opens its store at <data_dir>/gateway_store, inside the tree the gate audits.

Fix (#2627 M2). Persisted encodings are unchanged; no row is re-keyed, merged, normalized or deleted, and no preferred spelling is chosen. The store now holds one invariant: the by-grantee projection may accelerate discovery of canonical grants and may never create, hide or alter authority independently of the canonical AuthorityGrant records.

  • Reads prove authority from canonical rows. A Person query reads the whole projection, structurally parses every row, decodes each Person-tagged spelling with the production parser, keeps every row naming the requested principal under Did equality, de-duplicates by AuthorityGrantId, loads each canonical record, and returns only grants whose primary grantee is that principal. An Entity query keeps the exact-prefix scan — Entity identity is the exact string, the region is length-framed so no id can be a prefix of another, and no alias relation exists to miss — and is verified against the primary identically. Liveness still comes from the primary's revoked_at, as it always did. Order is (valid_from, grant id), so the reinstatement seam's "most recent revoked grant" is a function of the data and not of scan order.
  • Distinct grants stay distinct. The de-duplication unit is the canonical AuthorityGrantId, never the grantee. A principal may legitimately hold several grants, and two ids are two grants; only rows naming one id collapse.
  • Stale and malformed evidence are told apart by whether the write protocol can produce them. A row pointing at a missing primary, or at a primary naming another grantee, is stale: reads filter it and it confers nothing. Filtering cannot hide a real grant — the only grant such a row could name is the one its own id names, whose canonical record does not exist or does not name this grantee, and every other row is judged on its own primary; a live grant can never present this shape, because row and primary are written in one transaction and no path deletes a grant primary. A row this writer could never have produced — framing that does not parse, a length field overrunning the key, a suffix that is not valid_from ‖ grant id, an undefined variant tag, a Person region naming no principal, or a value naming a different grant than its own key — is malformed, and every read refuses with grant_by_grantee_index_malformed: rows=<n> reason=<class>. Refusing is the only answer that neither invents authority nor hides it: an uninterpretable row cannot be attributed to a principal, so it cannot be ruled out as the row naming the one being asked about, and dropping it would answer "no authority exists" on evidence never read. No spelling, entity id or grant payload travels in the error.
  • No writer-side guard was needed, and the reason was reproduced rather than assumed. The projection is append-only: put_authority_grant and put_mandate_with_grants_atomic insert primary and index in one transaction, the startup backfill derives its key from the primary's own grantee and so cannot introduce a spelling the primary does not carry, and revocation touches the primary alone. Equivalent duplicate rows for one canonical grant are therefore harmless after verification and de-duplication, which a fixture pins.
  • No new lock, for a stated reason. sled's scan is not a snapshot, but because nothing deletes or re-keys a projection row, a straddling enumeration can only miss rows written after it began and never lose one written before — and every row it sees is proven against its own primary. That is precisely the guarantee #2704 and #2707 needed a namespace lock to obtain, because those projections retire rows on replacement and a scan could miss the old row and its replacement both. A behavioural fixture holds the property under concurrent writes. What remains is a subset read — a scan racing the multi-grant atomic commit may return a set matching no single committed state. This read is not a linearizable snapshot of a grantee's grant set and is not claimed to be. What is claimed: authority is never invented, because each returned grant was loaded from its own primary and checked there; and every outcome reachable under a subset read is also reachable by reordering the concurrent commits. The gate only becomes more restrictive. Revocation can miss a grant minted concurrently — the schedule where it ran first — and can never miss one committed before it began. Reinstatement's has_active_in_domain precheck is the one consumer a subset read makes more permissive: it can mint where it would have declined, which is the same two active grants two concurrently accepted minting decisions produce with no race at all, and the precheck is best-effort against a duplicate proposal rather than mutual exclusion. None of this is new — the pre-M2 reader scanned and loaded each primary with the same absence of a snapshot. M2 changes which rows are discovered and adds the primary-grantee check; it touches snapshot semantics not at all, and removes the part that was representation-dependent.
  • The scanner registers the prefix as icn-gateway/adr0014_grant_by_grantee, Equivalent, Established, under a third structural descriptor, PrincipalRegion::LengthPrefixedTagged { principal_tag: 0x01 }. Neither existing layout can read this key. AnchoredThenOpaque looks for a terminator byte, and here the spelling is preceded by a binary length field and followed by an arbitrary u64, so no terminator names the boundary — every row would be unreadable and the gate would refuse unconditionally. WholeKey is worse than a refusal: the canonical shape would carry the u32 length prefix verbatim, and that field is derived from the spelling, so two aliases of one principal would differ in it, land in different shapes and form no collision group at all — a silent false-clear. The new variant therefore replaces the region including its own framing, which is derivable from the spelling, while the valid_from and grant id after it are carried byte-for-byte and never parsed. The descriptor learns no ADR-0014 authority semantics: it knows a big-endian u32 length field, a tag value, a principal subregion and an opaque tail. The width is fixed rather than a descriptor field: one layout declares this region and it frames with a u32, so a configurable width would buy a hand-rolled accumulator — and its silent truncation above eight bytes — for no present caller. principal_tag stays a parameter because it is the discrimination that keeps an entity id spelling did:icn: from being read as a principal. The collision unit is (grantee principal, exact valid_from, exact grant id), so two spellings for one grant are one group and two grants for one principal are two shapes — not an assertion that a principal holds one grant, which would be false. An Entity-tagged region names no principal and is reported principal-free rather than unreadable; a region whose framing does not parse is unreadable and blocks, as the gate's fail-closed rule requires.

What this does not claim. The Equivalent disposition is correct because the reader is now projection-correct; on a binary without this fix the same scan verdict would be meaningless (§11.2). No live store was scanned or repaired. No rebuild for this projection is added: the existing backfill_grant_by_grantee_index remains an additive recovery for pre-index databases, not a reconciliation, and a verified read path is sufficient for software closure — retiring historical rows, if it is ever wanted, is a separate explicit operation. Every claim here is fixture evidence. No merge rule is authorized for authoritative state, and no grant body or projection row was re-keyed.

11.7 Disposition of commons/holders/by_did/ — P5 index over a P2 row, FailClosed / Established (#2627 M3)

The defect, at the seam rather than in either half. A member's profile update reaches CommonsInner::update_display_name through two gateway callers — api/members.rs::update_member_profile (PUT /v1/members/{coop_id}/{did}/profile, self-service) and api/coops.rs's member-add, which sets a display name best-effort. Both authorization checks on the first route compare Dids, and since I7 that is principal equality: the self-service check caller_did != did and the membership check coop.members.iter().any(|m| m.did == did) both correctly accept a second textual spelling of an enrolled member. Immediately below them the mint decided existence with a single exact-key get on commons/holders/by_did/<spelling> and, on a miss, derived the new holder's permanent id as SHA-256(did.to_string()). Neither half is wrong on its own. Composed, they are:

principal-level authorization
+ spelling-level existence test
+ spelling-derived durable identity
= a second durable Commons holder for one principal

Reproduced on 12902244 before any fix, through the production route: the alias-spelled request returned 200 OK — not 403, not 404, so both gates did accept it — the commons/holders/by_did/ row count went 1 → 2, two primary holder records existed, their ids differed, and the first holder survived with its display name intact. This is not a failed lookup whose damage is a missing read; it is a created fact. Two concurrent alias-spelled updates for one principal both succeeded.

What M3 establishes. Classification before mint, and nothing else:

exact index row for this spelling?
  value is not 64 lowercase hex digits            → REFUSE  holder_index_malformed
  primary proven, filed under this same spelling  → ordinary update
  primary cannot be resolved                      → REFUSE  holder_index_primary_missing
  primary filed under a different spelling        → REFUSE  holder_index_primary_mismatch
no exact row → read the whole holder-by-DID namespace
  a row under any spelling names this principal   → REFUSE  holder_principal_already_indexed
  a row cannot be parsed as a spelling at all     → REFUSE  holder_index_malformed
  every row read, none names this principal       → mint, byte-for-byte as before

Absence of a principal is a claim about every spelling, so it is proven against the namespace and never inferred from the one key that missed. The exact-spelling read happens first so the ordinary case — one canonical spelling looked up by itself — does not pay for a scan; only the branch that is about to create durable identity reads the namespace.

The value's shape is checked before the primary is looked up, because a value that is merely UTF-8 would otherwise fail to resolve and be reported as a stale index whose primary is missing. Both refuse, so nothing is unsafe either way — but a dangling reference and an unreadable one are different defects with different remedies, and the classification must not conflate them.

The exact-hit arm requires the primary to be filed under the byte-identical spelling, not merely to name the same principal. get_holder_by_did resolves an index row and loads the primary without re-checking it, so a crossed row would otherwise have handed one principal's caller another principal's record to mutate; and a primary carrying the same principal under a different spelling would, on write-back, have filed a second index row under the body's spelling — a normalization no rule here authorizes. Every row put_holder writes agrees with its body by construction, so no healthy row reaches that arm.

Writer bytes unchanged. A proven-absent spelling still derives anchor_bytes = holder_id = SHA-256(did.to_string()), holder_did = Did(spelling), personhood_level = Weak. Changing that derivation would re-key every weak holder already persisted, which is a migration and not a guard. It is pinned by a regression fixture precisely so M3 cannot smuggle one in.

Registration. commons/holders/by_did/<spelling> is registered in n2a_keyspaces as icn-commons/holder_by_did, FailClosed / Established, PrincipalRegion::WholeKey with did_ends_key — the writer appends the spelling and stops — as the fourteenth whole-key layout. Fail-closed is the disposition because two spellings reach two independent CommonsHolderRecords with their own status, personhood level, affiliations and baseline rights: merging them decides which holder is a member in good standing, and no identity-layer rule decides that. Two rows naming one holder id are refused on the same ground — the equal values make a rebuild's choice look free, and Equivalent is a claim about derivation only the owning domain can make.

The prefix claims the index subspace and nothing lexically near it. The two siblings — commons/holders/<hex holder id> and commons/holders/by_anchor/<hex anchor id> — are keyed by opaque hex, carry no spelling in the key, and do not start with the registered prefix; a did:icn: appearing in a sibling's stored value is not key material and is invisible to a key scan. Sibling isolation is pinned by fixture.

Gate and runtime agree. Before registration the gate blocked a store holding a single weak holder, as UNCOVERED — and blocked two distinct principals for the same reason, a false refusal. Registered, one valid row and two distinct principals are clear, and the alias pair refuses as icn-commons/holder_by_did / FAIL-CLOSED. This unblocks the by_did holder rows and nothing else: a deployment whose holders came through enrollment also carries commons/anchors/by_did/<anchor DID> rows, which no descriptor covers, so such a store still refuses to start after M3. The fixtures build the anchorless weak-holder store the profile-update path produces; the anchor and steward indexes are M4's. A fixture drives one real commons.sled through both layers: the state the mint seam produces is a state the gate opens, the alias pair the gate refuses is the state the seam refuses to create, and the seam then refuses to add a third spelling to it. Both wrappers write sled's default tree, so the gate reads exactly the bytes Commons wrote.

Concurrency, bounded honestly. CommonsHandle owns commons state behind one tokio::sync::RwLock and every mutation takes the write lock; both gateway callers reach the seam only through it. CommonsInner::update_display_name contains no .await between classifying and writing, so a task that holds the guard runs the check and the write to completion: there is no interleaving window, and none was closed here. The guard is concurrency-correct because of that ordering, not because it defends a race. A multi-threaded fixture spawns two same-Principal updates released together by a barrier and asserts exactly one mints — an outcome check under real lock contention, not a race detector. It did fail before the guard existed, for the plain alias reason above rather than because any interleaving was observed. Production opens commons.sled once: icn_core::supervisor::lifecycle creates the handle and injects it, and icn_gateway::server uses the injected handle rather than opening a second store; the standalone with_sled_path fallback runs only when no handle was injected, and sled holds an exclusive flock on the database directory, so a second process cannot open it. M3 therefore claims serialization within the one handle production constructs, and claims nothing about hypothetical independent handles over one path — that configuration is not production-reachable, and no new lock was added.

Crash ordering — a nonclaim. put_holder writes the primary, then the by-DID index, then the by-anchor index as three separate durable operations. A crash between the first and second leaves a primary record that this by-DID guard cannot discover, so a later mint under another spelling would not see it. That is pre-existing crash-partial persistence debt, not something M3 introduces or repairs, and no transaction layer is added here. The claim M3 makes is therefore the narrow one:

An I7 alternate spelling can no longer cause a second weak-holder mint through a coherent holder-by-DID namespace.

It is not the claim that two holders for one principal are impossible under every failure model.

What this does not do. No holder is merged, adopted, re-keyed or deleted; no alias row is removed or renamed; no index is rebuilt; no DID spelling is preferred; no weak holder is migrated. Existing duplicate holders are not dispositioned — where the registered index proves the collision the gate refuses and runtime mutation refuses, and choosing among already-derived duplicate holder ids stays a separate domain decision. get_holder_by_did is not made alias-transparent: a caller presenting spelling B still fails to find a holder indexed only as A, and the authorization consumers of that lookup — authority.rs::require_office_in_jurisdiction, api/membership/mod.rs, api/steward/mod.rs — remain fail-closed exactly as before. Commons identity is not Principal-transparent, and M3 does not claim it is. The sibling commons/anchors/by_did/ index is closed separately in §11.8 (M4a); the commons/stewards/by_did/ index, the StewardId derivation and the spelling-derived holder id itself are untouched and remain as §11.4 records them.

The guard is at one seam, and only one. get_or_create_holder → create_holder_from_anchor_with_name, reached from SDIS enrollment, also constructs a fresh holder (Weak when the anchor carries no attestations) and de-duplicates by anchor, not by principal. Two enrollments of one principal under two spellings therefore still produce two holders — exactly the state the new descriptor refuses at startup. That is a pre-existing behaviour and not a regression: before M3 this keyspace was UNCOVERED and blocked startup for any holder row. The claim is bounded accordingly — at most one newly-minted holder per decoded principal at the profile-update seam — and the enrollment seam is left for its own slice. That slice is M4a (§11.8), which closes it one layer earlier, at the anchor the holder is derived from.

11.8 Disposition of commons/anchors/by_did/ — P5 index over a P2 root, FailClosed / Established (#2627 M4a)

The defect is in the constructor, not at a seam. M3 (§11.7) closed the profile-update mint and recorded that get_or_create_holder → create_holder_from_anchor_with_name, reached from SDIS enrollment, de-duplicates by anchor rather than by principal. Tracing that path to its root shows the holder step is not where the defect lives. CommonsHolderRecord::new sets holder_id = anchor_id verbatim (icn-identity/src/commons.rs, "Holder ID is derived from anchor_id (they're the same for now)"), so holder identity is anchor-owned: one holder per anchor holds by construction, and no "one holder per principal" invariant exists anywhere in the type, in IDENTITY_SEMANTICS.md, or in the consumers. Duplicate holders on this path are a symptom. The duplicate anchors above them are the fact.

CommonsInner::create_anchor_from_enrollment decided existence with:

anchor.id = SHA-256("icn-anchor-v1" ‖ vui ‖ genesis)      genesis = fresh random per call
if store.get_anchor(hex(anchor.id)).is_some() { bail }    ← an id it just randomized

The check can never fire. Its two production callers — complete_enrollment (api/sdis/simple_enrollment.rs) and the dev standing bootstrap (api/commons/mod.rs) — decide existence with get_anchor_by_did, a single exact-key get that proves only that one spelling is unused. (icn-core's SDIS service and commons_mgr also call it, but both call sites are inside #[cfg(test)] modules; the commons_restart_helper binary ships without a required-features gate but is driven only by commons_persistence.rs.) The dev-bootstrap caller states in a comment that "create_anchor_from_enrollment errors on a duplicate anchor"; it does not, and that caller was relying on a guarantee the constructor never provided.

The one mechanism that does refuse a repeat is the VUI reservation in complete_enrollment, which returns "This identity has already been enrolled (VUI collision)". It is defeated twice over: compute_temporary_vui is SHA-256(did.to_string()), so two spellings of one principal reserve two different VUIs; and the whole block is if let Some(ref mgr) = steward_mgr.as_ref() with no else, so a deployment without a steward handle performs no duplicate check at all.

Reproduced on 99556959 before any fix, through CommonsInner, the seam both production callers reach:

enrol A               →  1 anchor primary, 2 anchors/by_did rows, 1 holder, 1 holders/by_did
enrol B (alias of A)  →  2 anchor primaries, 4 anchors/by_did rows, 2 holders, 2 holders/by_did

and — the finding that decided the guard's shape — the same is true of a repeat of the identical spelling:

enrol A               →  1 anchor, 1 holder
enrol A again         →  2 anchors, 2 holders; and both by_did rows silently RE-POINTED
                         at the new records, orphaning the first anchor and holder

So the uniqueness defect here is not "principal alias only". It is a re-enrollment / anchor identity defect of which the alias case is a strict subset — and the subset is the less destructive half, because two distinct spellings land on two distinct keys and therefore overwrite nothing. A guard that refused only the alias would be defeated by resubmitting the original spelling, so the question asked is the principal-level one, which subsumes both.

What authorizes the refusal, stated narrowly. The enrollment route already refuses a repeat in words — "This identity has already been enrolled (VUI collision)" — so refusing a second enrollment is not a behaviour this slice invents; it is one the route already intends and fails to deliver, because the VUI is spelling-derived and the whole check is skipped without a steward manager. That is the whole of the authority claimed. api/sdis/recovery.rs is not cited as a rule: its complete_recovery is a stub whose own comment lists steps 1–5 as unimplemented and which fabricates a UUID-hex DID that Did::from_str would reject, so its module doc-comment is a statement of intent about unwritten code and cannot establish anything. An earlier draft of this section cited it as authority; that was an overclaim and is corrected here.

What is enforced is one anchor per Principal, which is narrower than one anchor per human, and the difference is recorded rather than papered over. Enrollment identity is a per-ceremony device key, so a second device is a second Principal; and a recovery rotation, when it is implemented, issues the person a new DID. Either way the same human can present as a Principal with no row here, and classify_anchor_enrollment returns ProvenAbsent for them. This index is rotation-blind and device-blind, and M4a claims neither. The authority that would own "one anchor per human" is the VUI threshold-PRF in the SDIS ceremony and its reservation registry, not this namespace.

What M4a establishes. Classification before the first durable write, and nothing else:

exact index row for this spelling?
  value is not 64 lowercase hex digits            → REFUSE  anchor_index_malformed
  anchor id does not resolve to a primary         → REFUSE  anchor_index_primary_missing
  primary resolves                                → REFUSE  anchor_principal_already_enrolled
no exact row → read the whole anchor-by-DID namespace
  a row under any spelling names this principal   → REFUSE  anchor_principal_already_enrolled
  a row names no ICN principal at all             → REFUSE  anchor_index_malformed
  every row read, none names this principal       → enrol, byte-for-byte as before

classify_anchor_enrollment runs before the pseudo-VUI, the genesis draw and the placeholder key, because every value below that point is a durable identity artifact or a function of one: an existence question asked after them cannot be answered without first minting the thing it asks about. Refusing there also avoids the orphan the obvious alternative produces — create the anchor, discover the principal at the holder step, and leave a PersonhoodAnchor plus two index rows behind with no holder to reach them.

Two differences from M3 that are load-bearing, and were found by the fixtures.

  1. There is no PrimaryMismatch arm, and there must not be. A holder index row agrees with its primary by construction, because put_holder derives the key from holder.holder_did; a disagreement is a defect. An anchor index row carries no such relation. put_anchor files an anchor under anchor.to_did(), while put_anchor_did_index files the same anchor under the enrollment spelling, which appears nowhere in the anchor body. A key/body disagreement is the normal, intended shape of this namespace. Copying M3's check would refuse every enrollment row ever written.

  2. Rows are compared by decoded identifier bytes, never by parsing each suffix into a Did. Did::from_anchor_id builds its DID with new_unchecked over a SHA-256 anchor id, which is not a valid Ed25519 point roughly half the time, so Did::from_str rejects it. A classifier built on from_str reports healthy anchor-derived rows as unreadable evidence and then refuses every subsequent enrollment on the node — observed, in the first version of this guard. icn_identity::identifier_bytes_of_spelling is the decode Did itself delegates to and the one the collision scanner groups by, so the runtime refusal and the startup gate read these bytes the same way by construction rather than by coincidence.

The descriptor, and why a healthy store still clears. commons/anchors/by_did/ is registered FailClosed / Established, PrincipalRegion::WholeKey, did_ends_key, as the fifteenth whole-key layout and the eighteenth descriptor. The sibling commons/anchors/<hex anchor id> is outside the prefix rather than a member of it and is not cleared by this registration.

A healthy store holds two rows per anchor and that is not a collision: the enrollment spelling and the anchor-derived DID name two different principals, because the anchor-derived one is a function of a random anchor id. They land in different collision groups. What the descriptor refuses is two rows naming one principal. anchor_enrollment_gate_runtime_agreement.rs pins both directions against one real commons.sled — the state the seam produces is Verdict::Clear, including at ten distinct enrollments; the state it refuses to create blocks the start with icn-commons/anchor_by_did FAIL-CLOSED; and the seam then refuses to add a third spelling to the planted pair without repairing it.

What M4a does not claim.

  • No merge rule, no survivor, no repair. Refusal is the whole of the remedy. Already-derived duplicate anchors and holders are not dispositioned, re-keyed, deleted or merged.
  • get_anchor_by_did is not made alias-transparent. Reads stay fail-closed per spelling. A caller presenting spelling B still fails to find an anchor indexed only as A.
  • The dangling-row leak is recorded, not fixed. delete_anchor removes only the anchor's own derived-DID row, so a row written by put_anchor_did_index survives the deletion of the anchor it names. M4a classifies that state (anchor_index_primary_missing) and refuses to enrol over it; it does not clean it up. Pre-existing, not I7-caused.
  • The crash-partial window is pre-existing debt, and it defeats the invariant. put_anchor writes the primary and the derived index row, then put_anchor_did_index writes the enrollment row, in three separate operations with no transaction. Say the process dies between them: the surviving state is an anchor primary plus its derived row only. A later enrollment of the same spelling finds no exact row, scans, sees one row naming the anchor-derived principal — a different principal by construction — classifies ProvenAbsent, and mints a second anchor. The gate agrees, because two derived rows are two distinct principals. So a crash-interrupted enrollment still yields two anchors for one Principal, invisible to both layers. M4a narrows nothing here and closes nothing here; the guard is complete only modulo durability, and this is said explicitly so a reader cannot take it for more.
  • Two lockouts are created, and both are pinned rather than repaired. The guard reads only whether a primary resolves, never AnchorStatus, so (a) a principal whose anchor was revoked cannot enrol again under that key, and PersonhoodAnchor::reinstate refuses a revoked anchor outright, so there is no path back; and (b) a retry after a partial enrollment — a completion that failed after the anchor write, which complete_enrollment is deliberately fail-closed enough to produce — presents the same principal, reaches Held, and cannot complete. Both are fixtured (sdis_enrollment_anchor_identity.rs). Neither is newly introduced on the steward-manager configuration, where the VUI reservation already refused the repeat one step earlier; M4a extends them to the steward-less configuration, which is exactly the configuration that previously had no duplicate check at all and "recovered" by minting the duplicate this slice exists to prevent. Rolling an anchor back, releasing a revoked principal, and letting a retry adopt an existing anchor are all writes M4a does not authorize.
  • No claim is made about the identity semantics of an anchor. IDENTITY_SEMANTICS.md — the registered owner — states nothing about anchor or holder uniqueness and classifies Anchor as a legacy, ambiguous carrier that "serve[s] four different semantic classes from one constructor". What M4a enforces is a store-integrity property of one namespace: a constructor whose existence check tested an id it had just randomized now asks a question it can actually answer. RuleBasis::Established records only that fail-closed is what that constructor implements — the M1 formula (§4.2) — and nothing about whether a person is one person.
  • Forward dependency, recorded so it is findable from both sides. The healthy-state model here — two rows per anchor naming two different principals — rests on Did::from_anchor_id, which §11 I8 / N2-B schedules for removal. And api/sdis/recovery.rs lists "Update anchor → DID mapping" as step 4 of an unimplemented block (recovery.rs:295-300); when that lands, a rotation will write a row here for a new Principal pointing at an existing anchor — a state this descriptor clears and classify_anchor_enrollment reads as ProvenAbsent. Both belong to their own owners; neither is closed here.
  • The same-spelling re-enrollment behaviour change is stated, not hidden. A second enrollment of an already-enrolled principal now fails where it previously succeeded. That is a behaviour change on a non-I7 defect, adopted because the principal-level question subsumes a spelling-level one that resubmitting the original spelling would defeat, and because the enrollment route's own error message already says a repeat is invalid.
  • Recovery rotation is not wired into this index. A rotated principal has no row here, so the guard is Principal-scoped and rotation-blind. Not a regression — no row existed before M4a either — but it bounds the claim, and it is the identity owner's question whether the durable index should follow a rotation.
  • commons/stewards/by_did/ and the StewardId derivation remain untouched, as §11.4 records.

11.9 Disposition of v1:interest_idx: — P5 uniqueness lock over a P1 canonical row, FailClosed / Established (#2627 M4b)

The defect is in the uniqueness mechanism itself, not at a seam. SledListingsStore claims v1:interest_idx:<listing uuid>:<did spelling> with a sled compare_and_swap from None to a constant sentinel, and only writes the canonical v1:interest:<listing>:<interest uuid> row once that CAS succeeds. The CAS is genuinely atomic, and docs/features/ACTION_ITEMS_EXCHANGE.md was right that it is what prevents duplicates — but it is atomic on the spelling it is handed. The key is built from ListingInterest.from_did through Display, which after I7 still renders the original spelling, so one principal arriving under two of the twenty-three multibase spellings Did::from_str accepts reaches two different keys, wins two independent CASes, and gets two canonical interests. This is not a lookup projection whose loss a reader could recompute around: the row is the one-interest-per-member rule, so a second spelling is a second permission to write authoritative exchange state.

Reproduced on 35a4195fc5e5e064613e702d947b644dd79e850f before any fix, through the ordinary authenticated route (POST /v1/listings/{id}/interest, coop:write), not by manufacturing keys:

POST as spelling A  -> 201 Created
POST as spelling B  -> 201 Created          <- Did(A) == Did(B), A.as_str() != B.as_str()
POST as spelling A  -> 500 (duplicate)      <- the same-spelling CAS worked all along

v1:interest:<L>:*      2 rows      get_interests() -> 2 logical interests
v1:interest_idx:<L>:*  2 rows      both values the sentinel "1"
GET /interests (owner) -> 200, both interests listed, distinct messages preserved

The same run reproduced a second, independent defect in the maintenance pass. cleanup_orphaned_interest_indexes split the whole key on ':' and required exactly four components, but a real key is v1:interest_idx:<uuid>:did:icn:<multibase> — six components, because a DID spelling carries its own colons. Every row the writer produces was therefore skipped, and parts[3] would have been the literal "did", making the comparison below it unreachable. The pass returned cleaned = 0 on a genuine orphan, and the orphaned lock kept the affected principal refused ("You have already expressed interest in this listing") with no mechanism able to clear it.

What authorizes the refusal, stated narrowly. Two rows naming one principal under one listing are two ListingInterest records that can differ in message, offer, from_coop, id and created_at. Choosing between them is choosing which approach to a cooperative offer the owner sees, and no exchange rule in this repository authorizes first, latest, oldest, lexicographic, highest-offer or non-empty-message as a survivor. That is FailClosed, and the basis is Established because the live writer now refuses to create the state (icn_gateway::listings_mgr::SledListingsStore::classify_principal_for_listing) rather than because this document proposes a rule.

What M4b establishes. The decision is taken against the canonical rows, whose from_did is a real Did and therefore compares by principal — the rule InMemoryListingsStore::add_interest_if_not_duplicate already applied, so the two backends behind one trait now encode one domain rule instead of two:

canonical v1:interest: row does not decode            -> REFUSE canonical-interest-unreadable
canonical row filed here but names another listing    -> REFUSE canonical-interest-listing-mismatch
canonical row's from_did == this principal             -> DUPLICATE
uniqueness row value is not the writer's sentinel     -> REFUSE uniqueness-row-value-unrecognised
uniqueness key is not UTF-8 / outside the namespace   -> REFUSE uniqueness-key-*
uniqueness key's listing framing is not the writer's  -> REFUSE uniqueness-key-listing-framing-*
uniqueness key's spelling names no principal          -> REFUSE uniqueness-key-names-no-principal
uniqueness row decodes to this principal              -> DUPLICATE
otherwise                                             -> proven absent; CAS the spelling, then write

Two asymmetries in that ladder are deliberate. A uniqueness row naming this principal is a DUPLICATE whether or not its canonical row survived, so a live write never reads a missing canonical row as absence and writes a replacement — repairing evidence is the maintenance pass's job, not a mutation path's. And a row that decodes cleanly to a different principal is clear even when unbacked: refusal is for evidence that cannot be proven not to name the caller, and one member's orphaned lock must not close a listing to everybody else.

Every refusal carries a reason class and nothing else: reason_class returns a &'static str from a closed set and the defect enum holds no payload, so no DID spelling, interest message or offer can travel with it. It does not reach a client in any case — GatewayError::InternalError replaces the manager's text with a generic string — so the reason class is an operator-log fact.

Writer bytes unchanged. The CAS is kept, unmoved, behind the classification: it remains the unconditional storage-level decider of the same-spelling race. interest_index_key, interest_key, the sentinel value, ListingInterest.from_did and the Uuid::new_v4() interest id are all byte-for-byte what they were. Nothing is re-keyed, no row is rewritten, and no canonical DID spelling is introduced — a binary reverted to the pre-M4b code reads exactly the rows this one writes.

Concurrency is load-bearing here, unlike M3 and M4a. Proving a principal absent requires reading rows a concurrent alias-spelled request could add between the read and the CAS, and no single sled operation spans both — sled::TransactionalTree offers no range scan, so the check cannot be folded into the CAS. Classification and write are therefore serialised by a mutex on the store. The scope that mutex covers is one database: sled::Config::open takes an exclusive flock on the database file, so no second process — and no second sled::open in this one — can hold it, and icn_gateway::server builds exactly one SledListingsStore, owned by the one ListingsManager as a Box. Constructing a second store over a clone of the same Arc<sled::Db> would give it a second lock and leave only the CAS between them; nothing in production does. The sled lock fact is pinned as a test rather than asserted, because the scope argument rests on it.

Registration. n2a_keyspaces() gains icn-gateway/listing_interest_uniqueness at prefix v1:interest_idx:, PrincipalRegion::WholeKey, did_ends_key, FailClosed / Established, inventory row 26 — the sixteenth whole-key layout. The prefix stops at the namespace because the listing id varies and so cannot be run through the DID scheme the way ledger:treasury: is; it does not need to be, because v1:interest: and v1:listing: do not begin with v1:interest_idx: and so stay outside the descriptor rather than being claimed by it. The listing component is a canonical Uuid — hex and hyphens — so it cannot spell did:icn:, and the whole-key tokenizer finds exactly the one spelling that is a principal.

The collision unit is the pair (listing, principal). The canonical shape substitutes each spelling with its identifier bytes and carries every other key byte verbatim, so the listing id stays in the shape: two spellings of one principal on ONE listing group and refuse, while the same principal on two listings is two shapes and no group. That control is not cosmetic — a unit that dropped the listing would refuse to start every gateway holding an ordinary exchange, and it is pinned in both layers.

Gate and runtime agree. icn-gateway/tests/listing_interest_gate_runtime_agreement.rs drives one real sled database at the data-directory level the gate discovers: the writer accepts one interest and refuses the alias, the gate calls the resulting store Clear, a raw alias row planted the way a pre-M4b binary would have written it makes the gate refuse fail-closed naming this keyspace, and the writer still refuses to add a third spelling to that state. Ten listings for one member and ten members on one listing both stay Clear. Neither layer repairs anything.

Crash ordering, recorded rather than fixed. The order is unchanged: the CAS claims the spelling key, then the canonical row is written. A crash between them still leaves a lock with no canonical row, and the affected principal is still refused until the maintenance pass clears it. M4b narrows that debt rather than closing it — the pass could not recognise such a row at all before, so the window was unbounded in practice and is now one scheduler interval — and introduces no transaction system. An insert that returns an error still rolls the lock row back, as before. The scope of such a lock does widen: pre-M4b a principal could escape its own orphan by re-spelling, because the escape route was the defect itself. It is now refused under every spelling until the pass clears it.

Complexity. Classification is O(interests for this listing) plus O(uniqueness rows for this listing), both bounded by the listing's own prefix rather than the global namespace, and get_interests already performs the same scan on every listing read. Cleanup remains O(total uniqueness rows) × O(interests per listing) and is unchanged in shape. No principal-hashed secondary index was invented; that would be a new persisted key and therefore a migration.

What M4b does not claim.

  • No merge rule was invented. FailClosed is a refusal to decide, not a decision.
  • Historical duplicates are not reconciled. A store already holding two interests for one principal keeps both; reads still return both; the gate refuses to start over the uniqueness rows that accompany them. No survivor is chosen, and the canonical interests are never rewritten.
  • The guard is not alias-transparent. A member who already holds an interest under one spelling and asks again under another is refused, not served the first interest.
  • The canonical interest bodies are outside the key scanner. v1:interest:<listing>:<uuid> carries its principal in the value, not the key, so uncovered_did_key_shapes has never seen it and does not see it now. Registering the uniqueness rows does not register the primaries: they are runtime-guarded and scanner-blind, and their migration disposition is unresolved. This section does not close it.
  • A uniqueness row carrying no did:icn: at all is counted, not refused, by the scanner. Such a row is rows_without_did under a whole-key scan — the same invisibility it had before registration, since the uncovered-shape reporter also skips keys with no spelling. The writer cannot produce one, and the live path refuses it; the scanner's silence about it is stated here rather than fixed.
  • One lockout class is narrowed, none is eliminated. An orphaned lock still refuses its principal between the crash and the next maintenance pass.
  • The uniqueness projection can now lose a row for a live pair, and is not self-healing. The maintenance pass's pre-existing TOCTOU — and delete_interest_indexes racing a write — can remove the row for a (listing, principal) whose canonical interest survives. Uniqueness is unaffected, because the canonical row decides and a later request is answered from it before reaching the CAS. What is lost is evidence: the gate has one fewer uniqueness row to read for that pair. The race predates M4b and its uniqueness consequence is strictly improved by it, but it was unreachable while the pass recognised no production row, so M4b is what makes it reachable. Recorded, not closed.
  • ListingsStoreBackend::add_interest is not guarded. It writes a canonical interest with no uniqueness check and no index row, on both backends. It has no caller in the workspace and is not reachable through ListingsManager or any route, so it is outside the M4b defect surface — but it is the one remaining public way to construct the state this section exists to prevent, and closing the trait is left open.
  • Adjacent keyspaces are untouched. receipt:meeting_attendance:by_pair:, idx_device_owner:, idx_notif_recipient:, the apps/ledger-app indexes and action_item_by_assignee: remain open in §11.4. M4b changes no listings economics, matching, offer or settlement semantics, and no account or resource identity.
  • Behaviour change, stated. A canonical interest row that does not decode now refuses new interests on that listing where it previously did not. That listing's reads were already broken — get_interests propagates the same decode error — so this makes the write path consistent with the read path rather than introducing a new failure class.

11.10 Disposition of action_item_by_assignee: — P5 lookup projection over a P1 canonical row, Equivalent / Established (#2627 M4c)

Finding: derived, not authoritative. SledActionItemStore::save (the apps/governance store — not the unrelated icn_governance::SledActionItemStore, which implements no secondary index) writes one action_item_by_assignee:<assignee spelling>:<domain id>:<item id> row per assigned action item, valued by a constant sentinel b"1". Nothing else writes under the prefix, and the row carries no fact the canonical action_item:<domain>:<item> row does not already state: ActionItem.assignee: Option<Did> is the assignment. Every consumer of the projection — list_work_for_person (GET /gov/me/work), digest_overdue_items (GET /gov/digest) — wants action items, not rows. The index is therefore a P5 projection and the correct disposition is to prove and maintain it as one.

Assignment is not authority. The action-item routes authorize by domain membership and by item.created_by == user_did; nothing reads this index to decide whether a caller may act. What the index decides is visibility — whether a person is shown work that is theirs.

Defects verified against main at dfc296f1 before the fix (twelve fixtures failed on the unchanged store; the two headline ones are quoted):

  1. The I7 regression, writer half. save decided whether to retire the previous version's projection row with existing.assignee != item.assignee. Since I7 that is principal equality, so for two spellings of one person it is false: the old row survived and the new one was inserted beside it. Reproduced through the ordinary authenticated route — PUT /gov/domains/{domain}/action-items/{item} with an assignee field, which the handler feeds to parse_did and stores verbatim — and at the store, where the fixture printed both surviving keys:

    action_item_by_assignee:did:icn:f952ba87…:dom-respell:adfb82ad-…
    action_item_by_assignee:did:icn:zB3JMnLdrhUE…:dom-respell:adfb82ad-…
    

    One principal, two spellings, one action item, two durable rows.

  2. The I7 regression, reader half. list_by_assignee scanned action_item_by_assignee:<query spelling>: as an exact byte prefix, so an item assigned under one accepted spelling was invisible to a query in another. GET /gov/me/work and GET /gov/digest both derive the caller from claims.sub through parse_did, which preserves whatever the token spells, so a person authenticated under a second spelling was told they had no work.

  3. One trait, two identity semantics. InMemoryActionItemStore::list_by_assignee compares i.assignee.as_ref() == Some(assignee) — principal equality since I7 — and returned the item. The sled backend missed it. Two implementations of one trait answered one logical query differently, and only the backend production uses was wrong.

  4. The projection could manufacture work. The reader took coordinates from a row and returned whatever primary they named, never comparing that item's own assignee to the query. A planted or superseded row put another member's obligation into a person's work view.

  5. Canonical coordinates were not proved. A row naming (D, I) was read through to action_item:D:I without checking that the decoded item agreed, so one item's facts could be returned under another item's coordinates.

  6. Deletion left alias rows behind. delete removed only the row implied by the item's current spelling, so a superseded row outlived the item it named. delete_all removed canonical rows only and left every projection row for the domain.

  7. Alias rows would duplicate output once lookups became principal-wide.

  8. An unreadable canonical row read as absent. save matched Ok(Some(existing)) if … and fell through every other case to None, so a primary that failed to decode silently produced "no stale row" — stranding that version's projection row.

Fix (#2627 M4c). Persisted encodings are unchanged; no row is re-keyed, merged or normalized, and no preferred spelling exists. The store now holds one invariant: the assignee projection may accelerate discovery of canonical action items and may never create, omit, preserve or alter an assignment independently of canonical action-item state.

  • Physical and semantic equality are told apart, and this is M4c's core finding.

    semantic equality  answers  "same assignee?"      →  Did equality (principal, since I7)
    textual  equality  answers  "same persisted key?" →  exact spelling bytes
    

    Stale-row maintenance asks the second question — whether the exact key the previous save wrote is the exact key this save writes — so save now compares the two computed projection keys and retires the previous one only when it differs. For an alias pair that is one assignee and two keys: the old row is removed, the new one written, and the canonical row keeps the spelling it was given. This is projection maintenance, not identity migration.

  • Reads prove assignment from canonical rows. list_by_assignee reads the whole projection namespace, decodes each anchored spelling to the principal it names (the same decode Did equality performs), and for each row naming the queried principal loads the canonical row and requires that it exist, that its own domain_id and id are the coordinates the row named, and that its assignee is that principal under Did equality — before returning it. Results are de-duplicated by canonical identity (domain id, ActionItemId) and ordered by (created_at, domain id, item id), so the answer is a function of the data and is the same under every spelling. A row alone never yields an item, and an exact-spelling miss never means no work exists.

  • Every spelling ever observed is not written. The projection holds one row per assigned item, in the spelling the canonical row currently carries. Writing a row per observed spelling would make the durable row count depend on attacker-chosen input, could never be complete across the accepted encodings, and would turn observed aliases into durable history. Principal-awareness lives at read time instead.

  • Stale and malformed rows are told apart by whether the write protocol could produce them, and a malformed row is refused only where it cannot be attributed away from the caller. A row whose canonical item is missing, or whose canonical item names a different principal, is stale: the protocol can leave it behind, so it is filtered — and filtering is also what stops a planted row manufacturing an assignment. A row this store could not have written is malformed, and the disposition splits on attribution. A row whose anchor holds no readable spelling names no principal, so no query can rule it out and every query refuses. A row whose anchor names some other principal is ruled out by identity alone, whatever else is wrong with it, so one person's damaged row never blanks another person's work view — a deliberate departure from the whole-namespace refusal §11.3 uses, because this namespace is read per person and an authoritative namespace is not. Refusals carry bounded reason classes only (anchor-names-no-principal, primary-coordinates-mismatch, …): no spelling, domain id, item id, title, description or note travels in one, and no existing debug log gained a new field.

  • Writes keep the projection a superset of the truth, never a subset. save commits the stale removal, the canonical row and the new row in one sled transaction, which is stronger than the three-write protocol §11.3 describes. delete collects every projection row naming the item's coordinates — under any spelling — and removes them with the canonical row in one transaction. delete_all removes the domain's canonical rows first and then every projection row whose residual names that domain, so a crash can only leave extra rows, which reads filter. A row this store could not have written is not swept by either: a row nobody can parse is one an operator must disposition, and deleting it quietly would hide the only evidence it exists.

  • The projection can be recomputed. SledActionItemStore::rebuild_assignee_index derives the expected rows from every canonical action item and makes the projection equal to them, reporting rows kept, added, removed as stale and removed as malformed. It refuses before mutating anything if any canonical row is unreadable — a rebuild that skipped one would call that item's real rows stale. It emits each item's exact stored spelling and touches no canonical byte. It is an explicit operation, never a startup step: the gate is read-only (§10.1), the daemon has no post-open repair hook, and none is introduced. There was no backfill before this. The _meta:idx_version:assignee key described in the April 2026 handoff was never implemented; SledActionItemStore::new retains the database and does nothing else, and still does.

  • The scanner registers the prefix as icn-governance-actor/action_item_by_assignee, Equivalent, Established, under PrincipalRegion::AnchoredThenOpaque { terminator: b':' } — the third anchored layout and the first whose terminator is : rather than /. The assignee spelling is anchored immediately after the prefix and ends at the first : that leaves a decodable spelling behind. The residual must be opaque for the #2704 reason: a GovernanceDomainId wraps any String filled from a /domains/{domain_id}/… path segment, so a domain id may contain : and may itself be a spelling — a whole-key scan would read it as a second principal, normalize it, and group two rows the store holds apart. The collision unit is therefore (assignee principal, exact domain-id bytes, exact item-id bytes). The canonical action_item: rows are a lexical neighbour and not a member: the two prefixes diverge at their eleventh byte (: against _), and a structural pin fails if the prefix is widened to action_item.

  • One accepted spelling this key layout cannot carry, refused at the write boundary. The framing above assumes no accepted spelling contains : beyond the did:icn: scheme. That is true of base2 through base64 and base256emoji — every spelling ICN mints — and false in general: multibase registers an identity base under code \0 that passes its bytes through unmodified, and Did::from_str restricts no base, so a principal whose 32 identifier bytes are printable ASCII containing : has an accepted spelling. No private key is needed to produce one; searching printable-ASCII candidates for one that decompresses to a valid Edwards point succeeded on the fourth try. Reached through the ordinary authenticated route — create_action_item and update_action_item pass assignee to parse_did and store it verbatim — such a row would parse to no principal, and therefore refuse every by-assignee lookup on the node: /gov/me/work for every person, and /gov/digest silently, because it swallows the error. It would also be unrecoverable: delete and delete_all locate rows by parsed coordinates and never find it, and the rebuild retires it as malformed and immediately re-derives the identical key from the canonical row still backing it.

    SledActionItemStore::save therefore refuses such an assignee before a byte moves, and the rebuild refuses over a canonical row carrying one. This is a representability rule about this key layout, not an identity rule: the spelling still names a principal, Did still accepts it, and whether Did::from_str should accept identity-base spellings at all is an identity-layer question left to #2627. Refusing at the write boundary is also what keeps the store and the gate agreeing — the scanner's tokenizer admits neither \0 nor : as body bytes, so it independently calls such a row unreadable, and a row one layer reads and the other cannot is how a gate starts lying.

    This is a general caution, not a local one. Any keyspace that frames a spelling with a delimiter inherits the same assumption. :-framed and /-framed layouts elsewhere in the registry are not audited here and are not claimed safe; that audit is left open.

  • The Equivalent basis, stated. All four conditions hold. The canonical row is authoritative (ActionItem.assignee is the assignment; nothing reads the index to decide authority). The residual identifies exactly one canonical primary ((domain, item) is the primary key). The runtime verifies that primary — existence, coordinates and assignee — before returning anything. And aliases cannot manufacture distinct obligation facts: two spellings on one item resolve to one canonical row and de-duplicate to one result. Established records that this is what the shipped loader implements, not that a merge is authorized: nothing here merges, and the rows are equivalent precisely because keeping either one loses nothing.

Startup gate. icn/apps/governance/tests/action_item_assignee_gate_runtime_agreement.rs drives one real sled database, at the <store_path>/governance_action_items path the actor uses and the data-directory level the gate discovers, through both layers: a healthy projection is clear and reads under either spelling; historical alias rows on one item are clear and read as one item, with both rows byte-identical afterwards; one person holding four items across four domains is clear (the scale control a collision unit collapsed to the principal alone would break); and a row whose anchor names no principal refuses at both layers. A CLEAR means this spelling collision is safe under the registered projection disposition. It does not mean the projection is complete, current or authoritative (§11.2).

Alias equivalence and forged-projection correctness are deliberately not conflated. A row claiming another person's item names a different principal from the real assignee, so at the key level it is a second shape, no group forms, and the gate is clear — correctly, because nothing about those keys is ambiguous. What makes that state safe is the reader proving the canonical row's own assignee. The gate never reads a value and could not have decided it.

Concurrency, bounded to what is proven. save was already transactional and stays so. The remaining hazard is reader-side and is #2704's, in this projection's shape: scan_prefix is not a snapshot, so a save that only re-spells one assignee — writing …:<new>:<D>:<I> and retiring …:<old>:<D>:<I> — can be straddled by a scan that passed the new row's position before the commit and reaches the old row's position after it, collecting neither and reporting the item absent from a person's work when it is assigned to them before, during and after. Canonical verification filters stale rows; it cannot recover a row the scan never saw. The store therefore holds a process-wide RwLock in the §11.3 pattern: writers exclusive, by-assignee lookups shared and held across the scan and the canonical loads, the rebuild exclusive. It is process-wide rather than per instance because GovernanceManager::with_sled_action_items builds a second store over a database it shares with the program and milestone stores, a per-instance lock would coordinate nothing between two handles, and a sled database admits one process at a time.

What is proven is the lock's polarity, read from the lock itself rather than from timing, and that both paths take it. What is evidence and not proof is the stress fixture: 200 re-spelling saves against two concurrent alias readers, asserting the item is never reported absent or twice. With the read guard removed and nothing else changed it reproduced the blanked work view in 5 of 6 runs on the development host; with the guard it passes. No claim is made that no interleaving exists.

What this does not claim.

  • N2-A is not complete and #2627 does not close here. §9 stands. M4d — alternate store opener and startup-gate coverage — and the remaining §11.4 boundaries are unchanged.
  • Governance human-identity storage is not complete. Votes and membership remain behind IDENTITY_SEMANTICS §7.5 and are untouched. The registry test that previously excluded every keyspace whose name contains "governance" was a proxy for that gate; it now states the invariant directly — no keyspace naming a vote or a ballot, and exactly one registered governance keyspace, named — because the action-item projection is a governance keyspace that is emphatically not a vote or a membership row.
  • Historical alias rows are not reconciled and no survivor is chosen. rebuild_assignee_index exists and is idempotent, but nothing calls it automatically.
  • No live store was scanned or repaired. Every claim above is fixture evidence.
  • Behaviour changes, stated. A save over a canonical row that does not decode now refuses where it previously wrote; a by-assignee lookup now refuses over a projection row whose anchor names no principal, and over a row naming the caller whose canonical evidence is misfiled, where it previously returned a short answer or a wrong item. A by-assignee lookup now costs one scan of the projection namespace rather than one scan of the caller's spelling prefix — the same cost class as the store's existing per-domain scans, and the price of an answer that does not depend on which spelling a token happens to carry.
  • Adjacent keyspaces are untouched. receipt:meeting_attendance:by_pair:, idx_device_owner:, idx_notif_recipient: and the apps/ledger-app indexes remain open in §11.4. M4c changes no action-item API, no notification digest content, no meeting, role or scope semantics, and no governance voting.
  • Read cost, stated precisely. A by-assignee lookup now scans the whole action_item_by_assignee: namespace and loads one canonical row per principal-matching row. The store's existing scans are bounded by one domain; this one is bounded by total assignments across every domain the database holds, and it runs under the shared read guard. That is a real widening, not merely a re-shaped constant, and it is the price of an answer that does not depend on which spelling a token carries. It stays within the store's documented workload (< 1000 items per domain) and is not optimized here.
  • The repair operation has no operator surface. rebuild_assignee_index is public but reachable only from tests — no route, no icnctl verb. Absent an unframable row (refused at write time) and absent corruption, nothing needs it; exposing it is deliberately left out of M4c rather than claimed.
  • delete_all matches its two halves by different rules, and this is recorded rather than fixed. The canonical half uses the prefix action_item:<domain>:, which also matches a domain literally named <domain>:<something>; the projection half added here compares the parsed domain id exactly. A store holding both a and a:b as domain ids would therefore have delete_all("a") remove a:b's canonical rows while leaving a:b's projection rows, which reads then filter as stale. The over-wide canonical prefix predates I7 and changing it would change delete semantics, so M4c states the asymmetry instead of silently widening the new half to match a rule it believes is wrong.