From 18ab6732cd8bf6f2647a5b081679cd428a916230 Mon Sep 17 00:00:00 2001 From: Eric Moore Date: Tue, 27 Oct 2026 08:04:58 -0500 Subject: [PATCH] 0.5.240: a contact's node key comes before their binding On 0.5.239 with the canonical rolled, the canonical served the contact's announced owner-binding (#907 works) and linux refused it: persist admits `delegates_to(owner -> node)` only once it holds the node's Key row (`attested_key_id ... resolves as neither`), and 0.5.239 pulled the node's key only after the person resolved, so each waited on the other. A refused row is then held back by edge for a backoff window (CIRISEdge#544). The contact code already names the person's nodes (`owned_nodes`). Every add (not only a pending one) is now tracked with those node ids; the resolve pass pulls their keys first and asks for the person's records once they are held (after 60 s regardless, so a stale code cannot block). CONTACT UNRESOLVED gains code_nodes and code_node_keys_missing. The #907 probe now starts with the asker lacking the node key, as in the run, and resolves. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QriZxPx5aJvHXCGaTZmvKF --- Cargo.lock | 2 +- Cargo.toml | 2 +- evidence/CIRISServer.cc_impl.tsv | 16 +++---- src/contacts_chat.rs | 79 ++++++++++++++++++++++++++++---- tests/contact_key_pull_e2e.rs | 54 ++++++++++++++++++++-- 5 files changed, 130 insertions(+), 23 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 779a483d..ed77b773 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1231,7 +1231,7 @@ dependencies = [ [[package]] name = "ciris-server" -version = "0.5.239" +version = "0.5.240" dependencies = [ "anyhow", "async-trait", diff --git a/Cargo.toml b/Cargo.toml index 163ad219..106ae6df 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ciris-server" -version = "0.5.239" # 0.5.239 (OPENED): A CONTACT RESOLVES TO THEIR NODE (CIRISEdge#907) — after an add, a node behind the canonical held the contact's KEY but not their announced owner-binding, so the directed consent grant (the contact request) had no node to reach and request:android<-linux stayed red (five-platform run 2026-10-26). The pending-contacts pass now tracks an added contact until their person resolves to a node: every pass it subject-Pulls the person's records from each consent peer, then the resolved node's key, occurrence and route, and WARNs `contacts: CONTACT UNRESOLVED` after 60 s. ADOPT EDGE v43.2.1 (persist v59.1.0 / verify v20.2.0 unchanged): a conferred server answers a stranger's Attestation subject Pull with the subject's live, announced, federation-scope owner-bindings and root acceptances (CIRISEdge#907); it serves only once the CANONICAL runs it, so the canonical roll is part of this fix. PREVIOUS: 0.5.238 (OPENED): A PASTED PERSON'S KEY IS PULLED DIRECTLY, NOT QUEUED BEHIND REPAIR (CIRISEdge#904) — the first 0.5.237 five-platform run stalled add:linux->android with `KEY FETCH STALLED … edge_recovery_queue=Some(270)`: the canonical held the key and shared the root, but edge's recovery queue takes one name per Key round, peer-delivered names first, and a contact paste is unrouted, so it was never reached. The pending-contacts pass now also sends each consent peer a Key-plane subject Pull every pass (STALLED gains direct_pull_peers / direct_pull_failed); tests/contact_key_pull_e2e.rs reproduces the starved pick and proves the direct Pull lands the key. ALSO: ADOPT EDGE v43.2.0 (persist v59.1.0 / verify v20.2.0 unchanged) — pre-seal route fetch, dial-path re-keying (#867/#879), throttled steady-state WARNs (#875), opt-in bundle-bootstrap peers (#881); CC 5.4.3 link shaping is set to its default `off` at every ReticulumTransportConfig, so no node's posture moves. ALSO: CLIENT FLOOR 0.5.232 — it localizes the fourteen sign_owner_binding.* / upgrade_owner.* / contacts request ids, so they leave the debt list (MAX_UNCOVERED 33 -> 19). PREVIOUS: 0.5.237 (OPENED): CONTACT REQUESTS ARE VISIBLE (CIRISServer#818; FSD/MEMBERSHIP_INVITES.md §8) — a contact is consent each side authors, and GET /v1/contacts listed only the grants the owner signed, so being added was invisible until you added the person yourself (Eric x Francesco; the five-platform cross-platform chat stage measures it). GET /v1/contacts/requests and GET /v1/self/invites `contact_requests` list live chat-covering consent grants naming the owner's fedID or this node's keys, signed by a PERSON (not infrastructure, not the owner's devices), not withdrawn or expired, and not yet answered (the person is not already a contact — one rule, contacts_chat::contact_persons, shared with GET /v1/contacts); accepting is POST /v1/contacts. Persist's bounded AttestationQuery::to(key).dimension(consent:replication:v1), not an every-row scan. ALSO: IGNORE A CONTACT REQUEST, PRIVATELY (FSD §8.1): POST/DELETE /v1/contacts/requests/{grant}/ignore writes/withdraws the owner's LOCAL-tier contact:request:ignored:v1 row citing the grant (never promoted; the requester learns nothing); GET /v1/contacts/requests adds `ignored`. Decline (the requester learns) waits on persist's addressed placement, CIRISPersist#1121 (FSD §8.2). ALSO: A PASTED PERSON'S KEY IS RE-ASKED UNTIL IT LANDS, AND A STALL SAYS SO (CIRISServer#821): edge's recovery queue drops a name once its Pull is sent, so one lost reply stranded a contact at awaiting_key_body until the person pasted again (five-platform cross-chat stage, 2026-10-25; the canonical held the key and would serve it — tests/contact_key_pull_e2e.rs proves the round); the pending-contacts pass re-queues every 10 s and WARNs `contacts: KEY FETCH STALLED` after 60 s naming the hop to read next. PREVIOUS: 0.5.236 (OPENED): ADOPT EDGE v43.1.2 — the CIRISEdge#888 hot patch (Eric x Francesco chat, 2026-10-24): two keys held live signed routes for one Reticulum destination (Francesco's pre-split AGENT key n7zblo5hrl and his node gjqjv2jpfd), peers attributed the link to the agent key, and every transit KEX the node ran was refused link_seal_refused_claim_mismatch, so no sealed link and no chat. Attribution is now chosen: the KEX-claimed key when it is bound to the identity, then the node-typed key, then the lowest key id (a link attributed before its claim is re-attributed); an agent-typed transport key is named at ERROR (steward: agents do not announce at a transit key). Also edge #887 (a slow holder is not retired by one stall) and lockfile advisory bumps. persist v59.1.0 / verify v20.2.0 unchanged. Counters to watch on a node: peer_binding attribution_by_claim / attribution_by_node. PREVIOUS: 0.5.235 (OPENED): AN OWNER WHO ANNOUNCED STAYS ANNOUNCED ACROSS THE SPLIT — Eric x Francesco (2026-10-23): chat never started; the canonical held francesco -> at federation and nothing naming his node key. His install announced while the binding named the agent key; a later boot split a node key off and move_owner_binding_to_node_key wrote the moved binding at self (under a comment saying "same cohort"), so it reached only his own devices: the canonical never rooted his node at its steward (rooting_not_rooted_at_steward, advisory, stale unsigned route) and Eric's node found peer_nodes=0. node_key::carry_announce_to_node_key re-promotes the node key's binding to federation with the owner's pen when the actor's was announced; node_key::follow_owner_across_split (move + carry) runs on every boot of a split home, so already-split homes heal. tests/split_keeps_announce.rs fails on 0.5.234 (mutation-checked). PREVIOUS: 0.5.234 (OPENED): A STAGED OWNER-BINDING CLAIMS A NODE THE OWNER'S FED-ID WAS NEVER ON (CIRISServer#811) — two pre-universal-binding shapes hold their agent as unclaimed with setup/root closed (a ROOT exists): a managed agent owned only by its legacy `admin` ROOT (no owner-binding at all), and a node claimed remotely on <=0.5.188 whose binding stayed on the actor key across the 0.5.189 split. One-time migration, no new network door: POST /v1/setup/sign-owner-binding on the OWNER's device (loopback, owner session) signs delegates_to(owner -> node_key_id) with the owner's pen and returns it with the roster proof (#806) when the signer is a roster key, sending nothing; POST /v1/self/upgrade-owner on the target (loopback, the node's admin session — docker exec for managed agents) now takes that JSON as its body: unowned node key only, the binding must attest THIS node key (a load-balanced sibling's is refused, nothing written), proof through persist's gates first (claim_remote::apply_staged_owner_binding / stage_owner_binding). Boot names the state once: OWNED BUT UNBOUND (legacy_root | split_unfollowed) with the remedy (warn_if_owned_but_unbound), for anyone else who stood up managed agents. ALSO: AN AGENT'S DEFERRAL, THE WA'S ANSWER AND THE AMENDMENT IT AUTHORISES, AS SIGNED ROWS (CIRISAgent#1316; CC rc10 3.1.9.4 / 3.1.5; T7's agent atoms): in-process ciris_server.emit_deferral_request (deferral:request:v1, the agent's occurrence key, about its identity, in the room of its conversation with the person — community_key_id), emit_deferral_response (deferral:response:{approved|rejected}:v1 signed by the RESOLVER's own pen through owner_signer_capsule; delegated refused; a WA with no pen here is deferral.resolver_has_no_key; same room), emit_identity_amendment (identity_amendment:v1 with amendment_digest + authorized_by + evidence_refs, refused identity_amendment_unauthorized unless the cited response is admitted, approved, about this agent and signed by the node's owner = the seated WA per Eric; same room) and owner_record (the founding ROOT's name/role/created_at + owner_of, for the agent's GET /v1/users/{id}); src/agent_deferral.rs; non-owner WA pens are CIRISServer#814. ALSO: CSD/4 CATALOGUE V5 (#810): a promise names its realizer; T7 widened for the agent atoms, realized by CIRISAgent. PREVIOUS: 0.5.233 (OPENED): THE FOUNDERS ARE THE HOLDERS — src/quorum.rs (the retired fabric-node-founders reading of ciris-canonical, no production callers) and ComposePolicy::pin_founder_quorum deleted on the steward's ruling (CC 8.1.1 corrected on rc10: the canonical founders are the three accord holders; a serve node has no standing of its own, it is blessed by the accord quorum; FSD/FINAL_GENESIS.md step 10). ALSO: A ROSTER KEY CLAIMS FOR ITS FEDID (the manager claims the prod agents from a fedID roster key held locally): POST /v1/self/associate mints a ROSTER KEY named - (alias = the fedID, as add-device; a second associate of one identity on one node is a 409; CIRISServer#805) and records it with an occurrence row the fedID SIGNS through persist's signed gate, so it replicates (the trusted-local bind stored NULL signatures and never left the node; from a roster of two the row is a held proposal, 202 {held, proposal_id}); claim-remote ships the roster proof (the fedID's record + the signer's signed occurrence rows, claim_remote::roster_proof_for) and setup/root admits it for THAT signer only, after the PIN, through persist's gates, so the target's owner_of names the fedID at claim time (CIRISServer#806); the served --home decides its own key tier, undoing an import-time pin made for CIRIS_HOME / ~/ciris (key_tier, CIRISServer#807). ALSO: TWO PEERS LINK WITH NO CANONICAL (CIRISServer#808): GET /v1/federation/self-transport-destination serves this node's signed route row with its transit KEX pair (204 before edge publishes one), and POST /v1/federation/peering admits the peer's as peer_transport_destination through persist's signed route gate (only for peer_key_id, only with a pair; peer_transit_route = admitted|absent|refused) — under the sealed transport a link is sealed only to a pair authenticated to the dialed node, and on 0.5.232 two directly peered nodes had no way to get one (the client's two-node gate); native scenario peer_pair (no canonical) PASS, mutation-checked. 0.5.232 = 0.5.232 (TAGGED 2026-10-21 on 01eb6ff2): 0.5.232 (OPENED): THE SEALED TRANSPORT — adopt edge v43.0.0 / persist v59.0.0 / verify v20.2.0 (CC 5.3.3.5 E1, a FLEET BREAK: every RNS link is sealed by a hybrid transit KEX, an unsealed node exchanges nothing); the key plane travels as rows (eviction = the owner's withdraws of the identity:occurrence:v1 row; from a roster of two the self roster HOLDS an eviction or a third device as a proposal, CC 3.3.6.3 — occurrences_held / add_device.roster_change_held, landing routes CIRISServer#794); bundle v4: the final-genesis plan seeds each canonical's signed transit row (refuses final_genesis.serve_node_without_transit_pair); the canonical boot prime uses the published TRANSPORT key, never the federation key (the v43 InvalidProof race); CIRISServer#376 retired (the server's own route row stripped the transit pair). ALSO: THE CLIENT THAT SPEAKS ADD-DEVICE: client floor 0.5.229 (CIRISClient#189 — every add_device.* id localized; the reworded contacts copy, CIRISServer#776 / CIRISClient#194), KNOWN_UNLOCALIZED 32 -> 19. 0.5.231 = 0.5.231 (TAGGED 2026-10-18 on 52f899aa): 0.5.231 (OPENED): ADD A DEVICE APPLIES AT THE END OF FIRST RUN (FSD/ADD_DEVICE.md §2.1): the owner's approval is verified OFFLINE when it arrives — against the owner's full v3 code the approving device now carries (fedID fingerprint, Ed25519 key, ML-DSA-65 commitment) and the fedID the person entered — then HELD (memory + disk) and applied by POST /v1/setup/add-device/complete, so the claim lands at the same point in first run as a new identity's; the status names the owner's fedID so the wizard never asks for it; ownership::verify_signed_owner_binding is the verify half of apply_signed_owner_binding. Harness add_device scenario restored its lost return (merge damage). 0.5.230 = 0.5.230 (TAGGED 2026-10-17 on 9efbe142, PyPI 9 files): 0.5.230 (OPENED): KEY EXCHANGE COMPLETES ON A FRESH NODE (CIRISServer#788): adopt edge v41.1.1 (CIRISEdge#876) — an initiator round no longer closes on a responder Deliver that carries no envelope while its own Diff's bodies are outstanding, so the canonical's signed TransportDestination and identity occurrence (its KEX keys), which arrive in the later, larger Deliver over WAN, are applied instead of dropped (no_round_in_flight). Plus the peering pull: every newly gained consent peer is asked, by edge's subject Pull, for its own key/occurrence/route and for every record bearing on this node's owner (receive_axis::pull_on_peers_gained), so records are asked for rather than waited on. 0.5.229 = 0.5.229 (TAGGED 2026-10-17 on 84810a9b, PyPI 9 files): 0.5.229 (OPENED): THE SELF ROOM ASKS ABOUT THE BOUND KEY (CIRISServer#783 RCA): on an actor/node split the self-room drive resolved its owner through the node key (#628) but opened the owner pen on the engine signer (the ACTOR), which owns nothing, so every tick answered Unowned (Eric's desktop agent, every 30 s since the split); one bound_node_key helper now serves both, and tests/a_loop_has_no_bearer.rs refuses any for_owned_node call on a signer key. The import-time keyring pin inspects ONE home: a set CIRIS_HOME never falls through to ~/ciris (CIRISAgent on 0.5.228). 0.5.228 = 0.5.228 (TAGGED 2026-10-16 on eee83b43, PyPI 9 files): ADD A DEVICE (CIRISServer#177, FSD/ADD_DEVICE.md): a new device joins its owner's identity peer to peer over the first-contact plane (CIRISEdge#683, device-join kind 0x0000_0002); the owner consents on the existing device behind a time-boxed window and a receiver-derived matching code; A signs owner -> B and B's occurrence and re-wraps the self DEKs; B mints its own keys (no key material crosses); edge now gets the federation directory (first contact was dead in the server without it); TLC model formal/add_device. a RE-OPEN never supersedes a key (CIRISServer#783). Eric's desktop: his owner fed-ID was minted platform-sealed while the TPM was unusable (software tier); on 10-08 the TPM plugin became loadable and the next re-open minted a TPM master and archived the live key to *.superseded-pre-v8 (keyring f4ea1a2 / verify v20.1.0), so every owner act since fell to the machine path. src/key_tier.rs opens every sealed key from the tier the FILES say (software master → software, plugin seal → TPM, nothing → mint), heals an alias superseded into an EMPTY TPM tier (rename back, seal kept aside), and the embedded provisioning registers the owner seed dir before compose. 0.5.227 = 0.5.227 (RELEASED 2026-10-15 on 21869a68): the CSD-129 AGENT step in the native harness (`python -m harness.native halt_agent`): the T8 mesh plus the agent's own node fold booted on agent_handoff.json's env; a holder's halt fired AND confirmed on it; the agent's refused act (423) and its terminal ACTION_RESULT asserted HELD by the canonical (signed receipt + row) while the latch still stands; then resumed. Two processes meet through files under --work (CIRISAgent#1262 runner). 0.5.226 = 0.5.226 (TAGGED 2026-10-14 on 99be3e07, PyPI 9 files 23:45Z): evidence rows for the kill switch (CLM-halt-authority / CLM-accord-reach / CLM-halt-one-row at v0.5.225, CIRISConstitution re-pin 17); the Windows installer lane's budget (#761). THE LATCH HAS ONE READER (CC on re-pin 17, 4.2.1.1 "zero data disruption by construction"): 0.5.225 refused the HTTP trace ingest, the relay's persist_batch and both capture doors 423 accord_halt_in_effect while a halt stood; every one of those is a DATA door and 3 of 4 are the exact path a refused act's terminal ACTION_RESULT takes to the record (agent design, Eric 2026-10-14 on CIRISAgent#1231), so the pause would have erased its own evidence. 0.5.226 removes all four node-side readers and lens-core's set_halt_gate hook; the agent's act gate over GET /v1/accord/halt-status is the latch's only reader; tests/halt_gate.rs::no_data_door_reads_the_latch is the L3 mutant (any reader outside halt_gate.rs/compose.rs = red); CSD-129 step 3b flips from "ingest refused 423" to "ingest still admits". #766 (Eric 2026-10-14, blocks agent 2.16.0 from adopting 0.5.225): the local tee runs BEFORE the mock-LLM refusal on both capture paths — the agent keeps mock traces local-only (CIRISAgent#1244/#1246) and the tee (a plain file, never scored/replicated/read by edge) is the one sink they may reach; 0.5.225 refused first and the agent's staged QA saw 0 traces. Persist/scoring/replication stay refused for mock traces. 0.5.225 = 0.5.225 (RELEASED 2026-10-14 on 8a70209e): THE KILL SWITCH + SCORING RESTORED (#757/#758, Eric 10-13: pulled forward from 0.5.226 while 0.5.225 waited on edge v41.0.1): every capacity:sustained_coherence:v1 score ever issued was 0.0 because the 20-ROW gate was compared against n_eff (an effective rank ≤ 11); now the row gate runs before the computation, the band runs from a rank floor (1.0) to RATCHET's target (4.5), coverage needs min_feature_dim (4) features that VARY, each ACTIVE agent (a trace admitted within scorer.active_lookback_days, 30) is scored on its OWN latest window (the shared 500-row page gave each agent only its share), Indeterminate emits NOTHING (LC-AV-18), and every score names the consent rows it rests on (persist #1041 consent_basis). THE KILL SWITCH (CIRISConstitution#146, CC 4.2 rc7/rc8; Eric 2026-10-13) — a constitutional halt fires on ONE seated holder's row and latches on receipt; it is an AGENT PAUSE: the node keeps serving, no exit(42), no boot gate; the fuse lapses halt_fuse_secs after THIS node received it (24 h default) unless a strict-majority accord:lifecycle:confirmed names it; a strict-majority accord:lifecycle:active naming it ends it; thresholds are verify 20.1's (InvocationKind::required_signatures), never the server's; the node's ONE absolute gate (src/halt_gate.rs) refuses agent reasoning at every door (HTTP ingest 423 accord_halt_in_effect; lens-core relay + capture doors) and serves the latch on GET /v1/accord/halt-status for the agent act gate (FSD/CONSTITUTIONAL_HALT.md; CSD-129 on the T8 harness topology ran green). Substrate: persist v55.0.0 (rc7 registry; TraceRefused, InvalidReplaced, is_structurally_invisible) / verify v20.1.0 (invoke domain v2) / edge v41.0.0 (persist v55 adopt; durable_queue_depth read from the Edge). #759 already satisfied; #760 evidence rows re-pointed. From the earlier 0.5.225 opening: edge v40.0.12 (v40.0.11 never published either: its tag run failed on the dial-publish race, a fresh lane taken by a concurrent send before its dialer claimed it; v40.0.12 publishes lanes already claimed, unclaimed_ship_refused_total must stay 0) — a freshly claimed node admits its owner's rows at once: the claim and every local key write call edge's release_signer (CIRISEdge#858; the old node's rows had waited out a 1–6 h park after the claim, re-warned every round), with the park counters (rows_parked_on_signer, signer_releases, retry_suppressions, signer_park_evictions, refusal_memory_*/parked_on_signer_*) and recovered_links_total (#859: inbound links whose LinkEstablished the canonical dropped) served on /v1/federation/metrics; mock-LLM traces are also refused at the local capture doors (capture_event and the PyO3 capture), not only at the HTTP and relay ingest. From edge v40.0.11: (v40.0.10 never published: a gate failed and Codex found four reap fixes, including outbound use not refreshing the idle clock) — responder rounds and link-up timing by stage (responder_rounds_total, responder_link_up_seconds/_total), so an old peer whose links never complete a round shows as a signature; the HTTP ingest refuses mock-LLM batches (trace_mock_llm_refused, CIRISAgent#1244). From v40.0.10: the responder-side INBOUND idle reap (120 s, inbound_idle_bound; exempts transfers, LXMF serve, A/V holds and the identify window): the canonical is the responder for every other node's dial pool, and on 0.5.224 its inbound links grew ~95 per 5 min (agents on 0.5.223 never close theirs); inbound/outbound link gauges and closed-by-reason by direction, served on /v1/federation/metrics. Carries edge v40.0.9: openmls/libcrux panics contained in both MLS stacks and short-key KeyPackages refused at ingress (CIRISEdge#823; RUSTSEC-2026-0330/0331); the five #821 pool-reaper race fixes; the mobile wheels carry the PyO3 module (#824). persist v53.1.8 / verify v19.0.0 unchanged. 0.5.224 = 0.5.224 (OPENED): persist v53.1.8 / edge v40.0.8 / verify v19.0.0. persist v53.1.8: capacity_consent_stance (#1013) — the scorer's precheck and the emit gate ask ONE fold by principals (13 agents on the canonical were refused at emit every pass), plus read counters by door and fold, telemetry_snapshot() and cache_stats() (#1014). edge v40.0.8 = v40.0.7 (never released: cargo deny refused RUSTSEC-2026-0330/0331, now a dated ignore, CIRISEdge#822) + persist v53.1.8 — the dial pools reap links idle past 120 s and keep at most 4 idle per destination (CIRISEdge#819, the canonical's 1024 → 2048 link climb); edge telemetry P0 (#820): live transport counters on v40.0.x, link-pool gauges and closed-by-reason, round-duration and permit-wait histograms, all served on /v1/federation/metrics. /v1/federation/metrics names reticulum_link_count — the transport's established links, so a leviculum COMPLETION_MIRROR_OVER_ENVELOPE alarm (1024 → 2048 in 35 min on the canonical, CIRISEdge#819) can be told from a real link leak. 0.5.223 = 0.5.223 = A PEAK IS NOT A FLOOR: persist v53.1.7 / edge v40.0.6 / verify v19.0.0 — PASSED the bridge's 12-minute capped acceptance (copy of the live canonical home, genesis install at boot, 2 GiB, network none: max 439 MB, steady 85–275 MB, no OOM). edge v40.0.6 (release branch off v40.0.5): single-flight owner/consent memos and the sweep permit around the send-set, group-plane and recipient reads; persist v53.1.7: the trust-root walks bounded (the serve-tier walk on the post-genesis canonical 73 → 8 MB live, 2.4–3.8 → 0.23–0.44 s), group invitees, delegation granters, steward/owner node lists, V181. EARLIER IN THIS CUT: persist v53.1.5 / edge v40.0.5 (release branch off v40.0.4: the proactive push stops at its 256 KiB budget — every round toward a fresh or unreachable peer had fetched, decoded and trust-walked the whole advertised plane to ship ~17 rows, 8–15 min and 8–23 GB of churn per round, the capped runs' OOM) / verify v19.0.0 — the scorer pass at the canonical's shape decodes 17,100 rows / 48 MB instead of ~4.3 M / 1.63 GB (persist I538: the consent fold read the 2,401 rows about the node twice per agent), and the trace-summary read no longer sorts every payload. the 0.5.222 capped run on a copy of the canonical (2 GiB) read 152 MB live under 1.04 GB free-but-retained and OOMed; the node now returns retained heap to the kernel (malloc_trim every 30 s, CIRIS_MALLOC_TRIM_SECS, 0 = off; and after peering, once serving, after each scorer pass). 0.5.222 = 0.5.222 = THE CANONICAL CAN UPGRADE: persist v53.1.4 / edge v40.0.3 / verify v19.0.0 — a quorum-signed serve record replaces a held row the live quorum never signed (equal signed instant), and every federation_keys update keeps its whole scrub set; on 0.5.221 the canonical (whose own canonical-1 row kept only A1's scrub) booted PreGenesis{Community}. (OPENED) client floor 0.5.228 (the final-genesis ceremony UX, every 0.5.221 id localized). 0.5.221 = THE FINAL GENESIS, BAKED: persist v53.1.1 (the 2026-10-04 ceremony bundle sha256:db5e8e83…, the re-bake fix I497, the import door, two Codex P1s) / edge v40.0.2 / verify v19.0.0 — the release production nodes take to adopt the new root; roll the canonical first. A v3 bundle imports through persist's door and is accepted; GET /v1/trust-root/bundle serves the final genesis. Canonical-1's record keeps its July provenance on upgraded nodes (accepted, no practical effect). EARLIER IN THIS CUT: a self/family write on a node not registered as a personal device names the cause (`drive.device_class_not_personal`, persist v53 S1). Log retention ARCHIVES an aged-out file to .gz (verified, then the raw file goes) instead of deleting it — 0.5.220 (#721) erased every log older than 14 days on a node's first boot — and runs off the write path. 0.5.220 = THE FINAL GENESIS, ON PERSIST v53.0.1 / EDGE v40.0.1 / VERIFY v19.0.0 (FSD/FINAL_GENESIS.md; the maintainer: "the re-genesis — needs to be the final one"): one ceremony that all three accord holders sign mints the whole root — the canonical record(s), the labelled charter (successor set and per-holder recovery commitments), a quorum-scrubbed grant per canonical, the lifecycle row, the humanity-accord family record and the keyless ciris-canonical birth — in ONE v3 bundle that persist's assembler builds and verifies (`/v1/accord/final-genesis`: recovery-key, plan, sign, finish; instants stamped once; the recovery keys and successor set are the spares A2/B2/C2 from the original ceremony's record, genesis/accord_ceremony_keys.json); the 2-of-3 propose/cosign re-mint is retired (410). Every trust row is labelled (trust:charter / trust:confers / trust:accepts naming a head, one acceptance helper, re-authored once at boot); an old unlabelled portable bundle is refused at import. S1: an owned node registers its real device class (phone / laptop / server; CIRIS_DEVICE_CLASS; the desktop launcher sets laptop) and a misclassed occurrence is re-issued, so a person's own devices keep their self and family content; a per-epoch key not yet here is `awaiting_key` (409). No cross-version compatibility for NEW trust acceptance edges (the maintainer's ruling): roll the canonical and serve nodes first. Edge v40.0.1 (patch, same pins): six durability fixes from the #802 review — no repair loop on a fully held file, a withdrawn cohort file is hard-deleted, the sweep no longer stalls the puller, rooms past 4096 files are fully swept, and a self/family holding is never announced to an unknown peer (CC 5.2). (OPENED) ADOPTS EDGE v39.1.0 (persist v52.0.2 / verify v18.0.0): a pulled chunk DAG is not Stored or receipted until every chunk opens here, and a key arrival wakes the waiting pull (CIRISEdge#779); an unroutable peer backs off exponentially with jitter, kicks included, and resets on a learned path (CIRISEdge#794, the maintainer: the retry pacing was too fast). EARLIER IN THIS CUT: ADOPTS EDGE v38.1.1 / PERSIST v52.0.2 (verify v18.0.0 unchanged): edge instruments its anti-entropy round instead of holding a span guard across an await (CIRISEdge#790, with a gate that no guard is bound anywhere in its src/), so the 0.5.219 stopgap `src/span_leak_guard.rs` and its witness are deleted; persist v52.0.2 fixes the cohabitation abort (#705). A SEALED TRACE ROUNDS NOW (CIRISServer#719): lens-core's capture client seals and persists the agent's trace and stopped there, so the trace waited for the next 30 s anti-entropy tick — 6–33 s seal-to-stored on the production canonical under 0.5.219, measured from its `trace_events.admitted_at`; lens-core gains a host after-seal hook (`capture::client::set_on_sealed`) and the server installs the same coalesced replication kick it fires after a claim or a consent grant. A CONSENT IS A CLAIM (CIRISServer#713, CC 2.4 / CIRISConstitution#137): the row-type slot is closed at the five primitives plus two carriers, so the analyze consent, `POST /v1/auth/consent` and the watchlist enable are written as `scores` (never `consent` / `watchlist_config`); a node holding its analyze grant only under the old type re-authors it once as `scores`; the watchlist dimension gains its version segment (`watchlist:{id}:v1`) and the reader recognises an enable by dimension family, accepting the old type. THE CANONICAL STOPS SPINNING (production, 2026-10-02): the announced-relay refresh (#701) walked every identity with an occurrence — ~1,030 of 1,251 on the canonical were agents, nodes and itself — reading every row each had ever authored, and recomputed on every replication kick; passes took 8–86 s back to back and held ~1.5 of 2 cores reading the store at ~600 MB/s. Only a `user` key owns a node, so only persons are walked, and a nudge recomputes at most every 15 s (`NUDGE_FLOOR`; the 60 s periodic pass is unchanged). The trust-root re-mint is this cut (to come). PREVIOUS: 0.5.219 = 0.5.219 = THE CANONICAL STOPS CRASHING — a crash-fix cut (the maintainer, 2026-10-02: "Release a crash fix"); the trust-root re-mint moves to 0.5.220. ALSO IN THIS CUT, THE CLIENT FLOOR IS 0.5.226: `ciris-client` 0.5.226 carries every id the 0.5.218 surfaces emit (membership.*, custody.*, the claim, sessions, the contact flow, the drive and community planes), so 74 ids leave the guard's KNOWN_UNLOCALIZED debt list (93 -> 19) and the coverage ratchet drops 171 -> 19; 0.5.226 is also the first client that decodes the 202 `invited` of a community add. THE CRASH (CIRISEdge#790): edge's scheduler holds its `anti_entropy_round` span guard across an await, so round spans nest without bound — ~9,500 deep on the production canonical under 0.5.218, a stack overflow and exit 139 fifty minutes after deploy; the server's subscriber now disables that one callsite (`src/span_leak_guard.rs`), which makes the guard a no-op while every scheduler event still logs, until an edge release instruments the future instead. 0.5.218 = ADD A CONTACT BY CODE, AND A SECOND DEVICE THAT CATCHES UP (the maintainer's two flows). CONTACTS: `GET /v1/self/contact-code?nodes=` returns the person's v3 code (string + QR payload) naming the ANNOUNCED devices they choose, or none (resolved through the public directory) (#673); the device roster is public only for devices the person announced, per device (#655 ruling); `DELETE /v1/contacts/{key_id}` and `POST /v1/federation/peering/revoke` withdraw consent, signed by the person (#657); `GET /v1/admin/self` returns `owner_delegations` (#676). SECOND DEVICE: claim-remote links both devices at once, the device holding the pen re-wraps old self files for the new one, and `POST /v1/self/nodes/{node}/announce` announces another of my devices from the first (#678); replication runs 12 of 17 kinds, every kind routed or excluded by name (#646), so member additions, households and device revocations leave the node. FIXES: OAuth redirect parsed, same-origin path or exact loopback only (#672); a TPM request without a TPM refuses (#639); boot errors carry their whole cause chain (#586); a claimed node finds its consent peers (#601); reproposed chat commits are carried and the chat unopened reason is a token (#602). LADDERS: selffiles in CI (#622, #626), a `devices` scenario (second device joins; old self file opens; family rungs RED-EXPECTED on CIRISPersist#910/#916), ui-chat drives client 0.5.224 with nav derived from the client. Client floor 0.5.225. Known: history from BEFORE a device joined opens only for self files until CIRISPersist#916; restart re-admission waits on CIRISEdge#676 / CIRISPersist#911. WHERE EACH FILE IS (FSD/FILE_CUSTODY.md): `GET /v1/files/{id}/custody` names which of the person's devices hold a file, out of how many — persist's custody view (authorized as the bytes read) against edge's delivery receipts (CIRISEdge#738), each device `here`/`received`/`unknown`, every partial answer naming its `custody.*` reason (an inline file has no receipt until CIRISPersist#953; self/family copies are uncountable by design, CC 5.2); each drive row carries `custody: {devices_total, received_on}`; copy-to / remove-from a device is designed, not built. PREVIOUS: 0.5.217 = 0.5.217 = A FILE IS WHAT IT SAYS IT IS (CIRISServer#642/#641/#643/#644, for CIRISClient#77 and FSD/MEDIA_EDGE.md): the drive's write gate — every write door (upload, replace, move, notes) requires an RFC 6838 media type whose leading bytes agree with it (`drive.bad_media_type` 400, `drive.format_mismatch` 415 naming declared and sniffed — the same table as the client's RenderTier), and a filename cleaned per RFC 6266 §4.3 (path, control, bidi and zero-width characters removed; `drive.bad_filename` when nothing is left), so a name like `invoice\u202Etxt.exe` no longer replicates as written. The node states the PLAINTEXT digest of what it hands over (CC 5.3.2.5): `content_digest` on `GET /v1/files/{id}` and `/meta`, RFC 9530 `Repr-Digest` on a whole raw read; the edge pointer hash is renamed `at_rest_sha256` (it is the sealed blob's, never the plaintext's; `content_sha256` kept as a deprecated alias). `GET /v1/media/policy` publishes the node's render policy ahead of the ingest pipeline, with `renditions: false`. Notes say `here` like the drive (one word per fact; `drive::BYTE_STATES`). A digest SIGNED into the row, and renditions/placeholders, wait on CIRISEdge#638 and #614. PREVIOUS: 0.5.216 = 0.5.216 = ROSTERS AND FILES, END TO END (FSD/ROSTER_AND_DRIVE_CRUD.md): households (`/v1/families`: create, list, read, add/remove member, role, leave, dissolve, and an envelope → cosign → assemble flow for quorum families — closes CIRISServer#627), N-member communities and affiliations (`/v1/communities`, growth on persist v48's widening plane — CIRISServer#594), the owner's own devices (release a node, relabel a device, revoked devices listed; the self room uses edge's room-keyed handshake so a second device OPENS a file's bytes), and full drive CRUD (replace, rename, withdraw with a 410, move between circles, metadata, raw bytes with Range, a paged drive, notes edit/delete; the split-install viewer key fixed). Every roster read goes through the fold, enforced by a source gate. Known gaps, named in the FSD: a widened community member is refused reads until CIRISPersist#907; roster rows a PEER authors are signature-checked only until CIRISPersist#908; file rows are node-signed until CIRISEdge#675; family changes after creation do not replicate to a peer that already holds the family (CIRISPersist#910); subject take-back deferred. ALSO: ADOPTS EDGE v31.0.0 / PERSIST v48.0.0 (verify v16.1.0 unchanged; CIRISPersist#860/#905, CIRISEdge#672) — THE PROMOTION SWEEP SEES A CLAIMED MACHINE'S CONSENT: persist's `load_active_egress_grants` and the crossing's covering check take a grant by PRINCIPALS (the machine's own, or its bound steward's naming it in `for_key_id`), and V152 keys `consent_peer_set` by `for_key_id` — so an owned agent's sealed traces are promoted by its human's consent, the gate the production-shaped ladder had read as `offerable=0` since the owner's pen went live in 0.5.211. Also #860: a room's roster converges both ways (`CommunityMembershipWidening`, the 17th EnvelopeKind, appended — REPLICATION_POLICY_HASH / CONSENT_GRAMMAR_HASH / edge SERVE_ADVERTISE_POLICY_HASH re-pinned; ABI 5 unchanged); #672: the test-anchor block is persist's to mint (`mint_test_anchor` reproduces our block byte-for-byte; a `_MINTED_BY` line names the pair). EARLIER IN THIS CUT: ALSO ADOPTS EDGE v30.3.1 (CIRISEdge#671 → #673, persist v47.3.0 / verify v16.1.0 unchanged): a peer in no send set is minted a FirstContact recipient confined to exactly this node's allegiance facts (owner-binding, root acceptance, self-charter at federation audience), so a claimed agent Roots a canonical that consents to nobody on first contact — the server's `GET /v1/federation/allegiance` carry stays as the belt. THE ONE REMAINING GATE IS PERSIST'S (CIRISPersist#905): `promote_consented_backlog` loads grants the ENGINE key AUTHORED, and on every claimed install the human authors the grant (FOR the actor, since this cut — the covering door consents once per own key, node written last, and the actor is anchored at claim), so the sealed traces stay at `(self, local)`: the production-shaped ladder on the #636 merge read seal=3 trace_att=3 offerable=0. TAG HELD until persist folds the sweep over principals and edge pins it. PREVIOUSLY IN THIS CUT: THE HEAL LOOKED UP A KEY THAT EXISTS NOWHERE, AND THE TRACE PLANE RELIGHTS ON EDGE v30.3.0 — adopts edge v30.3.0 / persist v47.3.0 (verify v16.1.0 unchanged; CIRISEdge#659/#661/#662 → edge v30.2.0, CIRISPersist#901 → v47.3.0; CIRISServer#632): attribution is not trust (Attributed state; Rooted = a mutual VALID root through the owner-bindings), no baked production dial under a test root, a full responder inbox names its phase and never ends the driver, and `trust_root_valid` gains the holder-hardware leg (a valid root is as attested as its holders — the production seed's three accord holders carry real custody evidence; test roots carry persist's honest `SoftwareOnly_TEST` marker under a live test anchor). Server-side moves: `CallerScope::admits` takes the ROW's room as `cohort_target` (#893/#897), `files::in_room` is persist's gated reader door (caller named, `DrivePage`), `WithholdReason::RecipientNotRooted` classified as a verdict. THE OWNER ACCEPTS THE ROOT (CIRISServer#632 step 2 — the half the adoption alone does not deliver): edge's walk is `∃R ∈ roots_of(owner_of(N)) ∩ roots_of(owner_of(P))`, keyed on the OWNER, and the server had only ever written the NODE's `delegates_to(node → R)` (`accept_trust_root`, bootstrap default trust) — so under edge v30 no owned node was Rooted by any peer. `mesh_genesis::accept_trust_roots_as_owner` writes `delegates_to(owner → R, infra:attest, infra:serve)` at federation with the owner's pen (same envelope shape as the node's, idempotent, never the owner's own key), driven wherever the pen exists: at boot beside the baked-root install (the already-claimed fleet; the production canonical on its one boot with the operator's pen, which now heals the key record AND accepts the root), at both claim arms, and on trust-root import. Under a live TEST anchor the node's own key record carries persist's honest `SoftwareOnly_TEST` marker (a harness node that charters a root is a holder, and v47.3.0 reads an evidence-less holder as an invalid root; never in a production build). Witness: `tests/an_owner_accepts_the_roots_the_node_accepted.rs` — unowned → nothing; claimed → the owner's edge at federation, `trusted_roots_of(owner)` lists it, idempotent, and `trust_root_valid(owner, baked root).valid` by name. Fixture consequences of the persist major: the shared revocation fixture's self-charter signer now carries Layer-A evidence (persist's own remedy), and `trace_round_e2e`'s canonical ACCEPTS the root too — under edge v30 a pair is Rooted only when BOTH accept (it read "AGENT OFFERS 0 ref(s)" with `edge_exists: false` on the canonical). THE HEAL (CIRISServer#606): THE HEAL LOOKED UP A KEY THAT EXISTS NOWHERE (CIRISServer#606, found 2026-09-24 on the production canonical): `auth::ownership::rebind_owner_key_record` located the owner's row by `owner_signer.derived_key_id()`, but production's owner pen is built by `hardware_user_local_signer`, which hands `from_hardware_parts` the DERIVED id — so on that pen `key_id()` IS the registered id and `derived_key_id()` is `--`, a key registered nowhere (`identity.rs`, CIRISServer#597 §4: "every existing caller reads key_id()"; this door was the one that did not). The canonical is OWNED by the operator's portable fedID and runs the heal on every boot; with the pen placed on disk for one boot the pen resolved (three `responsible-user signer resolved` lines), the heal looked up the doubly-derived id, returned `Absent`, and the `Ok(_) => {}` arm at all three call sites (boot, claim, delivery) swallowed it — no line, row unchanged, and the row stays refused by every verify v15.2 peer. The gate `tests/owner_key_record_rebind.rs` was green throughout because its fixture pen is named by ALIAS, the one shape production never builds. FIX: the CALLER names the row (`owner_of`'s steward at boot/delivery, the binding's `responsible_user_key_id` at claim), the pen must be that key by `key_id()` or `derived_key_id()` and hold the row's pubkeys, and every outcome — `Rebound` / `Bound` / `Absent` / `Unbound{refusal}` / no pen — is logged by name at every site. Gate: a production-shaped pen (named by its derived id) must rebind, a stranger's pen is refused by name, and a source gate pins the key_id() convention. The one-off heal of the canonical's row is the same runbook re-run on this cut (CIRISServer#632 thread). PREVIOUS: 0.5.215 = EVERY IDENTITY THIS NODE IS, AND A FILE AT A CHOSEN COHORT. THE PUBLISH-OWN SET NOW CARRIES EVERY KEY THIS NODE IS (CIRISServer#629): on an actor/node split install (CC 3.4.7.3 Clause A) compose mints a NODE key, MOVES the owner-binding onto it and makes it the wire identity, and the node then attests its identity occurrences AND its `consent:replication:v1` grant with it — while the publish-own set held only `edge.signer_key_id()` (the actor key). Edge publishes a self-plane row only when its attester is in that set, so the node's OWN key record was never offered in a Key round: the far side admitted the actor key, never the node key, and then refused every row the node attested with "attesting_key_id … is not a registered federation key". Measured on the production canonical the same day — five agent keys admitted in one afternoon, ZERO node keys, ZERO rows of any dimension, no trace since 2026-09-18, and the grant authored on the agent and absent on the receiver. The second half of the same bug: `refresh_publish_own_set` asked `owner_of(edge.signer_key_id())`, but the binding had MOVED to the node key, so the owner never entered the set either (`owner_claimed=false` in the agent log). `own_key_ids(edge)` already answered "which keys are us" and two other call sites were gated on consulting it; this one was not. Both halves fixed and the existing gate extended to the publish-own seed — the gate itself had to be fixed twice (a fixed 4 KiB window stopped short of the code, then it matched the doc comment instead of the call), so it now reads whole function bodies with comments stripped. Same actor/node axis as the drive-auth finding below. ALSO: 0.5.215 = A FILE AT A CHOSEN COHORT, AND THE ROW REACHES MY OTHER DEVICE — adopts edge v29.5.0 (persist v46.3.1 / verify v16.1.0 unchanged; CIRISServer#626/#622, CIRISEdge#646): one door writes a file at any cohort and one drive lists everything this person can reach. `POST /v1/files {cohort: self|family|community}` seals through edge's `files::publish` at the ROOM's tier and crosses on a round-trip; `GET /v1/drive` lists it with each row's byte-state NAMED (`not_fetched` = "on another device", 409, vs `not_granted` = "this device holds no grant", 403 — different truths, and only a 200 is done); `GET /v1/files/{id}` opens the bytes. SELF-CHAT IS NOTE-TAKING: `POST /v1/notes` writes an UNNAMED `text/plain` row in the self room, `GET /v1/notes` reads them back, discriminated on type AND absent filename so an uploaded .txt is never reported as something the person wrote. THE SELF ROOM HAS A DRIVER: edge decides (`self_room::decide`), this host drives — `src/self_room_drive.rs` provisions the owner's content-KEM occurrence, then creates / publishes a KeyPackage / adds / removes on a named `loop_cadence` phase, under a watchdog. WHAT IS PROVEN, on a new two-node ladder (`harness/mesh-repro/scenarios/selffiles.sh` — ONE person, TWO devices): rooted=2 one_owner=1 roster=2 room=1 note=1 file=1 **mine_on_b=1** — the file is published, CROSSES, and the owner's second device LISTS it. WHAT IS NOT: `opened_on_b` (the BYTES) is blocked on CIRISEdge#656 — `decide` returns `PublishKeyPackage`/`Add` but edge can only author a KeyPackage or Welcome into a PAIR room, so those rows land in `chat:pair:v1:`, the creator holds the joiner's KeyPackage and cannot see it (`Added(0)` forever, room stuck at one member), and the second device reads `not_fetched`. The rung stays in the ladder and names that cause; it is promoted back to SUCCESS_STAGE when edge ships `*_attestation_in`. Three defects found by that ladder, none visible in the code containing them: (1) A LOOP CANNOT AUTHORIZE ITSELF WITH A SESSION — `owner_signer_capsule::acquire` reads a bearer, so a daemon could only pass `None` and refused `NotSignedIn` on EVERY tick forever, while the one arm that signs as the NODE (Create) worked, so a node created its self room, logged CREATED and looked healthy; `for_owned_node` is a second AUTHORIZATION (the node's owner binding, as `peer::owner_consent_pen` uses at boot), refusing an unclaimed node by name before opening any seed and verifying the resolved signer derives the key `owner_of` named. (2) THE HANDSHAKE IS NODE-ATTESTED — edge's `rows_in_room` resolves through `list_attestations_by(who)`, written for a chat room where a participant IS a person; a self room's participants are NODES while the person is one owner, so an owner-signed KeyPackage was invisible to the lookup. All four handshake builders take the node signer; the owner's pen stays the crossing ACTOR (the attester names the device, the actor names the authority, CC 3.3.6). (3) A HUNG TICK LOOKED LIKE A HEALTHY QUIET NODE — the loop logged on TRANSITION only, so a non-converging repeat and a wedged await were both silence; `Created`/`Added`/`Removed`/`Failed` now log every time and a tick that overruns 4x its period is abandoned by name. Gates: `tests/a_loop_has_no_bearer.rs`, `tests/the_self_room_handshake_is_node_attested.rs`. Harness: a `self` row needs no GRANT but `send_set_for`'s `nodes_of` is a DIRECTORY read, so the ladder PEERS the devices and waits for the ROSTER before sealing; and `grep -q` under `set -euo pipefail` reports FAILURE on a match (SIGPIPE to the writer, pipefail takes its 141) — it made three rungs read 0 against logs that plainly matched, fixed here and in run_embedded.sh / run_configured.sh. Community files get `comm_file`/`comm_file_on_b` on the chat ladder, the only ladder with two PEOPLE, measured before they gate. FAMILY files are NOT claimed: the consumer side is complete (edge FSD R0-R10, persist's `send_set_for` family arm) but nothing in production can charter a household family or admit a member — `family::create_family` has one caller (the keyless accord, via the trusted-local door) and `family::add_member` has zero (CIRISServer#627). PREVIOUS: 0.5.214 = A CLAIMED NODE READS ITS OWN CONFIG AGAIN — adopts edge v29.3.1 / persist v46.3.1 / verify v16.1.0 (CIRISServer#624 → CIRISPersist#888/#889, CIRISEdge#652): persist's read-side `self` gate compared a RESOLVED caller identity to a RAW row target, so after #873 a claimed node could not read its own `config:*` rows — `GET /v1/config` read `{}` and the second `POST /v1/federation/announce` 500'd on the agent's five-platform gate; v46.3.1 admits the caller's self-collective on both gate twins, and `tests/a_claimed_node_reads_its_own_config.rs` is the witness (red on v46.1.0, green here). Also: edge v29.2.0 `ScopeLifecycle::refresh_members` — `ensure_room_addresses` now REPAIRS a late-resolving member at the same epoch instead of only naming it (CIRISEdge#648); edge v29.3.0 / persist v46.3.0 `send_set_for` — a self row reaches the owner's second device (R2, CIRISPersist#884); verify v16.1.0 `create_federation_identity_in` (CIRISVerify#285) + the orphan `hw_token` stub removed (#283). Also in this cut (CIRISServer#618/#621, PR #620): the ASSOCIATE CUSTODY MATRIX — `POST /v1/self/associate` enrols this host from a hardware-held fed-ID with the two halves custodied independently (classical yubikey|tpm|software × pqc usb|tpm|software, composed through `HardwareRootedIdentity`), arguments validated before any token is touched, and both halves reported from `hardware_type()` rather than the request's words; plus HOME X IS HOME X — a `--home` now seals its own ML-DSA-65 half via verify v16.1.0's `create_federation_identity_in` + `preflight_keys_dir` (CIRISVerify#285), so the database, config, logs, Ed25519 seed AND post-quantum half are all scoped to that home; the 289-line relocation that stood in for the missing parameter is deleted. PREVIOUS: 0.5.213 = THE OWNER RECORD HEALS ITSELF — adopts edge v29.1.0 / persist v46.1.0 (verify v15.2.0 unchanged; CIRISServer#602 update, #606, #609, #612). The version names the NEXT TAG, not the last merge: 0.5.212 is the last tag, and everything since is this cut. Edge v25.2.0 = CIRISEdge#623 (inbound attribution never resolves to this node's own key; no responder is built for ourselves — the defence-in-depth half of #607) + persist v44.7.0 (#864: `Engine::rebind_key_record` — a same-key rebind is an admission). #606 uses that door: a registration record minted before persist #659 carries an envelope of {key_id} only, every verify v15.2.0 peer refuses it, and an OWNER's record rides the first identity round to every node the owner stewards — the production canonical held exactly one such row, the operator's portable identity stewarding nine nodes. auth::ownership::rebind_owner_key_record rebuilds the bound envelope from the row's OWN pubkeys, self-signs it with the owner's pen (refused if the pen does not hold those pubkeys), keeps every other member of the claim, and submits through the door; it runs wherever the owner's pen exists (boot via owner_consent_pen, the fold's author door, both claim-remote arms) and never for a hardware-custodied owner. delivery_status reports owner_key_record = bound | unbound | absent | unknown. Also in this cut: folded_health quiets host-pressure probes (a loaded CI runner made degraded_mode honestly true). THE ROUND SERVES AND THE BINDING CROSSES ON A ROUND-TRIP: the 0.5.212 canonical served zero rounds because edge's replication registry keyed coordinators by (peer, kind) with no role — a peer's inbound frames on a plane we also initiate to queued into the initiator's undrained channel and no responder was ever built (#612 / CIRISEdge#634, found by the in-process probe examples/probe_td_read.rs after three earlier layers: #607 self-prime, CIRISEdge#623 self-attribution, #609/CIRISEdge#624 first-contact carve-out). Edge v25.3.0 fixed the third layer (bootstrap attribution is an equality against the link's proven key, the announce rides the link); edge v26.0.0 fixes the fourth: two role-typed registry tables, CRPL frames carry the round id + direction (wire v3, 0x03 — refused by pre-v26 edges: a v26 node initiates only to v26 peers and answers every older initiator on the legacy path, so a mixed fleet shows one-directional timed_out until it crosses; ship every node on the same edge), and the drop that misdirected this RCA twice is now RouteOutcome::ReplyDropped{reason} + replication_reply_dropped_total / replication_routed_to_{responder,initiator}_total in the snapshot (now folded into GET /v1/federation/metrics `replication_round_routing` and delivery_status().replication_plane.round_routing). MEASURED on the first v26 chat-ladder run (2026-09-19, CIRISServer#612): both role tables build and drain, ReplyDropped 0, responders served on every plane — #634 is closed. On that edge the ladder was STILL red at `bound`, as it had been since v25.3.0 — the fifth layer: the bootstrap door compared a peer's FEDERATION key against the link's TRANSPORT identity (different keypairs on every node, bound by the SignedTransportDestination row), so a node's own record was refused on its own link, the link never attributed, and no Attestation/Community frame from it was admitted; node-a's owner-binding never reached node-b and the joiner withheld its KeyPackage (CIRISEdge#636, proven from the rows). Edge v26.1.0 fixes the door and adds a publish-side kick; the server kicks a coalesced round after the claim, the announce, each consent grant, the fold's author door and every chat row placed in a room (`compose::kick_replication`), so rows cross on a round-trip instead of the next 30 s tick. Persist v44.8.1 (#870): the two blob doors wrote the holds_bytes holder claim WITHOUT a signed_wire_index entry — advertised, never fetchable, so no peer ever learned a holder and every blob-backed body read not_fetched (the chat ladder's arrived=0 on every run since 0.5.207, mis-read as green by the verdict's high-water inference; arrived is REQUIRED now). Under that, three HOST doors the server had never set (found one per ladder run, each behind the last): edge's key-grant engine (ReplicationRuntimeConfig::engine — wraps stored, never projected), the scope-address lifecycle (CIRISEdge#499 — compose ARMED scope-native addressing and never DROVE it; install on Keyed with members = the roster's NODES, advance on epoch, seal on the `scope_seal` cadence) and the blob chunk source (EdgeBuilder::blob_chunk_source — the holder received the scoped BlobChunkFetch and dropped it). All three wired — and edge v27.0.0 (CIRISEdge#640) makes the half-wired node unconstructible: the three receive-side hooks are one type (`SealedContentWiring`: pull_sink ⇒ engine, revocations ⇒ engine) and a scope-native build is refused unless its chunk source declares `answers_scope()`; ours answers from the blob's community-DEK binding, then a referencing row through edge's own BlobMeaning. The last inch is persist's (CIRISPersist#873: `lookup_identity_for_occurrence` is LIMIT 1 with no order, so a node with its owner's login anchor resolves to ITSELF and `would_hold` refuses every community blob a non-author pulls as NotPartyTo); CIRISPersist#876 (the epoch's minter NAMED, not inferred from the author — the adopt wrote (community, person, epoch) while the seal and the key_grant set wrote (community, node, epoch), so authorize_viewer_by_tier missed on the minter member alone and every cross-node community body read NotGranted) is fixed in persist v46.0.0, and the chat ladder is GREEN: rooted=3 peered=2 pulling=1 contact=2 room=1 dark=1 bound=1 sent=1 arrived=1 hamburger=1 — A's bytes landed in B's transcript with their CEG identity intact. The ladder's `arrived` rung is REQUIRED (it had read 0 under `hamburger` since 0.5.207) and its verdict prints the cross-node timeline. Also: GET /v1/contacts rows carry the grant's envelope (#616). Tagged only on a green `bound` + `sent` + `arrived` + `served`. Persist v44.8.0 = the consent scope grammar (#866/#867): the envelope scope member is the normative carrier; one parser + one covering rule; the door refuses a malformed canonical token by name; retain: is honoured (ScopedStance.retain_until, expiry sweep emits consent:state:expired); the transfer grant's principle is read (only share/publish propagate). The server's bare analyze / share / view tokens parse unchanged; resolve_scoped_consent(_by_principals) keep their names. Client floor 0.5.203. SUBSTRATE: persist v52.0.1 / edge v38.1.0 / verify v18.0.0 — nobody joins a family or community without their own signed acceptance (invite → accept → widening, CIRISPersist#955 / CIRISConstitution#133), a quorum household's leave and dissolve replicate as amendments (CIRISPersist#956), every file is receiptable with the instant it was received (CIRISPersist#953), a family's files cross to the members' devices and its chunks are served under the family (edge `BlobMeaning::serve_scope`), a revocation names its subject by key digest (CIRISPersist#784), and a session claim carries its own signed lease (CIRISPersist#946); earlier in this cut on persist v51.3.0 / edge v37.1.0 / verify v18.0.0 — a file's name and type are sealed with its bytes and read through `drive::describe` (CIRISEdge#698), the person signs file rows (#675), the self-room consent sweep no longer widens a placed row (CIRISPersist#919), the dimension registry is strict (a gate checks every dimension the server spells), a withdrawn file stays withdrawn at the depth it was admitted under (#690), and a TPM-less host keeps its MLS state on disk under the software master; earlier in this cut on persist v49.0.0 / edge v32.1.0 / verify v17.1.0 (roster co-signatures and quorum proofs, the engine as blob evictor so a holds_bytes claim is retracted before its bytes go, Android generation custody credited as persist measures it (#339), the self-room Rejoin arm). FILE TRANSFER: a 25-file corpus over every supported type and size class, round-tripped on Linux/macOS/Windows and accepted by the first device; opening it on a second device waits on CIRISPersist#919 (the consent sweep widens self-room rows) and one size band on CIRISEdge#687. EVICTING A DEVICE is one signed act that replicates (CSD-037): `release` and `occurrence/revoke` converge on `self_devices::evict_device`, which withdraws the owner-binding AND writes the occurrence revocation through persist's SIGNED door (signed by the owner's pen, so the IdentityOccurrenceRevocation plane carries it) and kicks replication; until now a release revoked no occurrence and a revoke wrote the unsigned local-only row, so the #646 plane was routed but empty. Already-shared history stays readable by the evicted device (no DEK rotation, no re-encryption); only new content and new room epochs are withheld. CONSENT TO JOIN: adding a member to a family or community is refused (409 membership.consent_required) until the joiner can consent (CIRISPersist#955 / CIRISConstitution#133; FSD/MEMBERSHIP_INVITES.md) — direct adds, quorum adds, and founding rosters naming anyone but the founder. +version = "0.5.240" # 0.5.240 (OPENED): A CONTACT'S NODE KEY COMES BEFORE THEIR BINDING — on 0.5.239 (canonical rolled) the canonical served the contact's announced owner-binding (#907 works) but linux REFUSED it: persist admits `delegates_to(owner → node)` only once the NODE's Key row is held (`attested_key_id … resolves as neither`), and 0.5.239 pulled the node's key only after the binding resolved — each waited on the other (five-platform run 2026-10-27). The contact code already names the person's nodes (`owned_nodes`): the resolve pass now pulls those node keys first and asks for the person's records once they are held (after 60 s regardless), and every add — not only a pending one — is tracked until the person resolves. PREVIOUS: 0.5.239 (OPENED): A CONTACT RESOLVES TO THEIR NODE (CIRISEdge#907) — after an add, a node behind the canonical held the contact's KEY but not their announced owner-binding, so the directed consent grant (the contact request) had no node to reach and request:android<-linux stayed red (five-platform run 2026-10-26). The pending-contacts pass now tracks an added contact until their person resolves to a node: every pass it subject-Pulls the person's records from each consent peer, then the resolved node's key, occurrence and route, and WARNs `contacts: CONTACT UNRESOLVED` after 60 s. ADOPT EDGE v43.2.1 (persist v59.1.0 / verify v20.2.0 unchanged): a conferred server answers a stranger's Attestation subject Pull with the subject's live, announced, federation-scope owner-bindings and root acceptances (CIRISEdge#907); it serves only once the CANONICAL runs it, so the canonical roll is part of this fix. PREVIOUS: 0.5.238 (OPENED): A PASTED PERSON'S KEY IS PULLED DIRECTLY, NOT QUEUED BEHIND REPAIR (CIRISEdge#904) — the first 0.5.237 five-platform run stalled add:linux->android with `KEY FETCH STALLED … edge_recovery_queue=Some(270)`: the canonical held the key and shared the root, but edge's recovery queue takes one name per Key round, peer-delivered names first, and a contact paste is unrouted, so it was never reached. The pending-contacts pass now also sends each consent peer a Key-plane subject Pull every pass (STALLED gains direct_pull_peers / direct_pull_failed); tests/contact_key_pull_e2e.rs reproduces the starved pick and proves the direct Pull lands the key. ALSO: ADOPT EDGE v43.2.0 (persist v59.1.0 / verify v20.2.0 unchanged) — pre-seal route fetch, dial-path re-keying (#867/#879), throttled steady-state WARNs (#875), opt-in bundle-bootstrap peers (#881); CC 5.4.3 link shaping is set to its default `off` at every ReticulumTransportConfig, so no node's posture moves. ALSO: CLIENT FLOOR 0.5.232 — it localizes the fourteen sign_owner_binding.* / upgrade_owner.* / contacts request ids, so they leave the debt list (MAX_UNCOVERED 33 -> 19). PREVIOUS: 0.5.237 (OPENED): CONTACT REQUESTS ARE VISIBLE (CIRISServer#818; FSD/MEMBERSHIP_INVITES.md §8) — a contact is consent each side authors, and GET /v1/contacts listed only the grants the owner signed, so being added was invisible until you added the person yourself (Eric x Francesco; the five-platform cross-platform chat stage measures it). GET /v1/contacts/requests and GET /v1/self/invites `contact_requests` list live chat-covering consent grants naming the owner's fedID or this node's keys, signed by a PERSON (not infrastructure, not the owner's devices), not withdrawn or expired, and not yet answered (the person is not already a contact — one rule, contacts_chat::contact_persons, shared with GET /v1/contacts); accepting is POST /v1/contacts. Persist's bounded AttestationQuery::to(key).dimension(consent:replication:v1), not an every-row scan. ALSO: IGNORE A CONTACT REQUEST, PRIVATELY (FSD §8.1): POST/DELETE /v1/contacts/requests/{grant}/ignore writes/withdraws the owner's LOCAL-tier contact:request:ignored:v1 row citing the grant (never promoted; the requester learns nothing); GET /v1/contacts/requests adds `ignored`. Decline (the requester learns) waits on persist's addressed placement, CIRISPersist#1121 (FSD §8.2). ALSO: A PASTED PERSON'S KEY IS RE-ASKED UNTIL IT LANDS, AND A STALL SAYS SO (CIRISServer#821): edge's recovery queue drops a name once its Pull is sent, so one lost reply stranded a contact at awaiting_key_body until the person pasted again (five-platform cross-chat stage, 2026-10-25; the canonical held the key and would serve it — tests/contact_key_pull_e2e.rs proves the round); the pending-contacts pass re-queues every 10 s and WARNs `contacts: KEY FETCH STALLED` after 60 s naming the hop to read next. PREVIOUS: 0.5.236 (OPENED): ADOPT EDGE v43.1.2 — the CIRISEdge#888 hot patch (Eric x Francesco chat, 2026-10-24): two keys held live signed routes for one Reticulum destination (Francesco's pre-split AGENT key n7zblo5hrl and his node gjqjv2jpfd), peers attributed the link to the agent key, and every transit KEX the node ran was refused link_seal_refused_claim_mismatch, so no sealed link and no chat. Attribution is now chosen: the KEX-claimed key when it is bound to the identity, then the node-typed key, then the lowest key id (a link attributed before its claim is re-attributed); an agent-typed transport key is named at ERROR (steward: agents do not announce at a transit key). Also edge #887 (a slow holder is not retired by one stall) and lockfile advisory bumps. persist v59.1.0 / verify v20.2.0 unchanged. Counters to watch on a node: peer_binding attribution_by_claim / attribution_by_node. PREVIOUS: 0.5.235 (OPENED): AN OWNER WHO ANNOUNCED STAYS ANNOUNCED ACROSS THE SPLIT — Eric x Francesco (2026-10-23): chat never started; the canonical held francesco -> at federation and nothing naming his node key. His install announced while the binding named the agent key; a later boot split a node key off and move_owner_binding_to_node_key wrote the moved binding at self (under a comment saying "same cohort"), so it reached only his own devices: the canonical never rooted his node at its steward (rooting_not_rooted_at_steward, advisory, stale unsigned route) and Eric's node found peer_nodes=0. node_key::carry_announce_to_node_key re-promotes the node key's binding to federation with the owner's pen when the actor's was announced; node_key::follow_owner_across_split (move + carry) runs on every boot of a split home, so already-split homes heal. tests/split_keeps_announce.rs fails on 0.5.234 (mutation-checked). PREVIOUS: 0.5.234 (OPENED): A STAGED OWNER-BINDING CLAIMS A NODE THE OWNER'S FED-ID WAS NEVER ON (CIRISServer#811) — two pre-universal-binding shapes hold their agent as unclaimed with setup/root closed (a ROOT exists): a managed agent owned only by its legacy `admin` ROOT (no owner-binding at all), and a node claimed remotely on <=0.5.188 whose binding stayed on the actor key across the 0.5.189 split. One-time migration, no new network door: POST /v1/setup/sign-owner-binding on the OWNER's device (loopback, owner session) signs delegates_to(owner -> node_key_id) with the owner's pen and returns it with the roster proof (#806) when the signer is a roster key, sending nothing; POST /v1/self/upgrade-owner on the target (loopback, the node's admin session — docker exec for managed agents) now takes that JSON as its body: unowned node key only, the binding must attest THIS node key (a load-balanced sibling's is refused, nothing written), proof through persist's gates first (claim_remote::apply_staged_owner_binding / stage_owner_binding). Boot names the state once: OWNED BUT UNBOUND (legacy_root | split_unfollowed) with the remedy (warn_if_owned_but_unbound), for anyone else who stood up managed agents. ALSO: AN AGENT'S DEFERRAL, THE WA'S ANSWER AND THE AMENDMENT IT AUTHORISES, AS SIGNED ROWS (CIRISAgent#1316; CC rc10 3.1.9.4 / 3.1.5; T7's agent atoms): in-process ciris_server.emit_deferral_request (deferral:request:v1, the agent's occurrence key, about its identity, in the room of its conversation with the person — community_key_id), emit_deferral_response (deferral:response:{approved|rejected}:v1 signed by the RESOLVER's own pen through owner_signer_capsule; delegated refused; a WA with no pen here is deferral.resolver_has_no_key; same room), emit_identity_amendment (identity_amendment:v1 with amendment_digest + authorized_by + evidence_refs, refused identity_amendment_unauthorized unless the cited response is admitted, approved, about this agent and signed by the node's owner = the seated WA per Eric; same room) and owner_record (the founding ROOT's name/role/created_at + owner_of, for the agent's GET /v1/users/{id}); src/agent_deferral.rs; non-owner WA pens are CIRISServer#814. ALSO: CSD/4 CATALOGUE V5 (#810): a promise names its realizer; T7 widened for the agent atoms, realized by CIRISAgent. PREVIOUS: 0.5.233 (OPENED): THE FOUNDERS ARE THE HOLDERS — src/quorum.rs (the retired fabric-node-founders reading of ciris-canonical, no production callers) and ComposePolicy::pin_founder_quorum deleted on the steward's ruling (CC 8.1.1 corrected on rc10: the canonical founders are the three accord holders; a serve node has no standing of its own, it is blessed by the accord quorum; FSD/FINAL_GENESIS.md step 10). ALSO: A ROSTER KEY CLAIMS FOR ITS FEDID (the manager claims the prod agents from a fedID roster key held locally): POST /v1/self/associate mints a ROSTER KEY named - (alias = the fedID, as add-device; a second associate of one identity on one node is a 409; CIRISServer#805) and records it with an occurrence row the fedID SIGNS through persist's signed gate, so it replicates (the trusted-local bind stored NULL signatures and never left the node; from a roster of two the row is a held proposal, 202 {held, proposal_id}); claim-remote ships the roster proof (the fedID's record + the signer's signed occurrence rows, claim_remote::roster_proof_for) and setup/root admits it for THAT signer only, after the PIN, through persist's gates, so the target's owner_of names the fedID at claim time (CIRISServer#806); the served --home decides its own key tier, undoing an import-time pin made for CIRIS_HOME / ~/ciris (key_tier, CIRISServer#807). ALSO: TWO PEERS LINK WITH NO CANONICAL (CIRISServer#808): GET /v1/federation/self-transport-destination serves this node's signed route row with its transit KEX pair (204 before edge publishes one), and POST /v1/federation/peering admits the peer's as peer_transport_destination through persist's signed route gate (only for peer_key_id, only with a pair; peer_transit_route = admitted|absent|refused) — under the sealed transport a link is sealed only to a pair authenticated to the dialed node, and on 0.5.232 two directly peered nodes had no way to get one (the client's two-node gate); native scenario peer_pair (no canonical) PASS, mutation-checked. 0.5.232 = 0.5.232 (TAGGED 2026-10-21 on 01eb6ff2): 0.5.232 (OPENED): THE SEALED TRANSPORT — adopt edge v43.0.0 / persist v59.0.0 / verify v20.2.0 (CC 5.3.3.5 E1, a FLEET BREAK: every RNS link is sealed by a hybrid transit KEX, an unsealed node exchanges nothing); the key plane travels as rows (eviction = the owner's withdraws of the identity:occurrence:v1 row; from a roster of two the self roster HOLDS an eviction or a third device as a proposal, CC 3.3.6.3 — occurrences_held / add_device.roster_change_held, landing routes CIRISServer#794); bundle v4: the final-genesis plan seeds each canonical's signed transit row (refuses final_genesis.serve_node_without_transit_pair); the canonical boot prime uses the published TRANSPORT key, never the federation key (the v43 InvalidProof race); CIRISServer#376 retired (the server's own route row stripped the transit pair). ALSO: THE CLIENT THAT SPEAKS ADD-DEVICE: client floor 0.5.229 (CIRISClient#189 — every add_device.* id localized; the reworded contacts copy, CIRISServer#776 / CIRISClient#194), KNOWN_UNLOCALIZED 32 -> 19. 0.5.231 = 0.5.231 (TAGGED 2026-10-18 on 52f899aa): 0.5.231 (OPENED): ADD A DEVICE APPLIES AT THE END OF FIRST RUN (FSD/ADD_DEVICE.md §2.1): the owner's approval is verified OFFLINE when it arrives — against the owner's full v3 code the approving device now carries (fedID fingerprint, Ed25519 key, ML-DSA-65 commitment) and the fedID the person entered — then HELD (memory + disk) and applied by POST /v1/setup/add-device/complete, so the claim lands at the same point in first run as a new identity's; the status names the owner's fedID so the wizard never asks for it; ownership::verify_signed_owner_binding is the verify half of apply_signed_owner_binding. Harness add_device scenario restored its lost return (merge damage). 0.5.230 = 0.5.230 (TAGGED 2026-10-17 on 9efbe142, PyPI 9 files): 0.5.230 (OPENED): KEY EXCHANGE COMPLETES ON A FRESH NODE (CIRISServer#788): adopt edge v41.1.1 (CIRISEdge#876) — an initiator round no longer closes on a responder Deliver that carries no envelope while its own Diff's bodies are outstanding, so the canonical's signed TransportDestination and identity occurrence (its KEX keys), which arrive in the later, larger Deliver over WAN, are applied instead of dropped (no_round_in_flight). Plus the peering pull: every newly gained consent peer is asked, by edge's subject Pull, for its own key/occurrence/route and for every record bearing on this node's owner (receive_axis::pull_on_peers_gained), so records are asked for rather than waited on. 0.5.229 = 0.5.229 (TAGGED 2026-10-17 on 84810a9b, PyPI 9 files): 0.5.229 (OPENED): THE SELF ROOM ASKS ABOUT THE BOUND KEY (CIRISServer#783 RCA): on an actor/node split the self-room drive resolved its owner through the node key (#628) but opened the owner pen on the engine signer (the ACTOR), which owns nothing, so every tick answered Unowned (Eric's desktop agent, every 30 s since the split); one bound_node_key helper now serves both, and tests/a_loop_has_no_bearer.rs refuses any for_owned_node call on a signer key. The import-time keyring pin inspects ONE home: a set CIRIS_HOME never falls through to ~/ciris (CIRISAgent on 0.5.228). 0.5.228 = 0.5.228 (TAGGED 2026-10-16 on eee83b43, PyPI 9 files): ADD A DEVICE (CIRISServer#177, FSD/ADD_DEVICE.md): a new device joins its owner's identity peer to peer over the first-contact plane (CIRISEdge#683, device-join kind 0x0000_0002); the owner consents on the existing device behind a time-boxed window and a receiver-derived matching code; A signs owner -> B and B's occurrence and re-wraps the self DEKs; B mints its own keys (no key material crosses); edge now gets the federation directory (first contact was dead in the server without it); TLC model formal/add_device. a RE-OPEN never supersedes a key (CIRISServer#783). Eric's desktop: his owner fed-ID was minted platform-sealed while the TPM was unusable (software tier); on 10-08 the TPM plugin became loadable and the next re-open minted a TPM master and archived the live key to *.superseded-pre-v8 (keyring f4ea1a2 / verify v20.1.0), so every owner act since fell to the machine path. src/key_tier.rs opens every sealed key from the tier the FILES say (software master → software, plugin seal → TPM, nothing → mint), heals an alias superseded into an EMPTY TPM tier (rename back, seal kept aside), and the embedded provisioning registers the owner seed dir before compose. 0.5.227 = 0.5.227 (RELEASED 2026-10-15 on 21869a68): the CSD-129 AGENT step in the native harness (`python -m harness.native halt_agent`): the T8 mesh plus the agent's own node fold booted on agent_handoff.json's env; a holder's halt fired AND confirmed on it; the agent's refused act (423) and its terminal ACTION_RESULT asserted HELD by the canonical (signed receipt + row) while the latch still stands; then resumed. Two processes meet through files under --work (CIRISAgent#1262 runner). 0.5.226 = 0.5.226 (TAGGED 2026-10-14 on 99be3e07, PyPI 9 files 23:45Z): evidence rows for the kill switch (CLM-halt-authority / CLM-accord-reach / CLM-halt-one-row at v0.5.225, CIRISConstitution re-pin 17); the Windows installer lane's budget (#761). THE LATCH HAS ONE READER (CC on re-pin 17, 4.2.1.1 "zero data disruption by construction"): 0.5.225 refused the HTTP trace ingest, the relay's persist_batch and both capture doors 423 accord_halt_in_effect while a halt stood; every one of those is a DATA door and 3 of 4 are the exact path a refused act's terminal ACTION_RESULT takes to the record (agent design, Eric 2026-10-14 on CIRISAgent#1231), so the pause would have erased its own evidence. 0.5.226 removes all four node-side readers and lens-core's set_halt_gate hook; the agent's act gate over GET /v1/accord/halt-status is the latch's only reader; tests/halt_gate.rs::no_data_door_reads_the_latch is the L3 mutant (any reader outside halt_gate.rs/compose.rs = red); CSD-129 step 3b flips from "ingest refused 423" to "ingest still admits". #766 (Eric 2026-10-14, blocks agent 2.16.0 from adopting 0.5.225): the local tee runs BEFORE the mock-LLM refusal on both capture paths — the agent keeps mock traces local-only (CIRISAgent#1244/#1246) and the tee (a plain file, never scored/replicated/read by edge) is the one sink they may reach; 0.5.225 refused first and the agent's staged QA saw 0 traces. Persist/scoring/replication stay refused for mock traces. 0.5.225 = 0.5.225 (RELEASED 2026-10-14 on 8a70209e): THE KILL SWITCH + SCORING RESTORED (#757/#758, Eric 10-13: pulled forward from 0.5.226 while 0.5.225 waited on edge v41.0.1): every capacity:sustained_coherence:v1 score ever issued was 0.0 because the 20-ROW gate was compared against n_eff (an effective rank ≤ 11); now the row gate runs before the computation, the band runs from a rank floor (1.0) to RATCHET's target (4.5), coverage needs min_feature_dim (4) features that VARY, each ACTIVE agent (a trace admitted within scorer.active_lookback_days, 30) is scored on its OWN latest window (the shared 500-row page gave each agent only its share), Indeterminate emits NOTHING (LC-AV-18), and every score names the consent rows it rests on (persist #1041 consent_basis). THE KILL SWITCH (CIRISConstitution#146, CC 4.2 rc7/rc8; Eric 2026-10-13) — a constitutional halt fires on ONE seated holder's row and latches on receipt; it is an AGENT PAUSE: the node keeps serving, no exit(42), no boot gate; the fuse lapses halt_fuse_secs after THIS node received it (24 h default) unless a strict-majority accord:lifecycle:confirmed names it; a strict-majority accord:lifecycle:active naming it ends it; thresholds are verify 20.1's (InvocationKind::required_signatures), never the server's; the node's ONE absolute gate (src/halt_gate.rs) refuses agent reasoning at every door (HTTP ingest 423 accord_halt_in_effect; lens-core relay + capture doors) and serves the latch on GET /v1/accord/halt-status for the agent act gate (FSD/CONSTITUTIONAL_HALT.md; CSD-129 on the T8 harness topology ran green). Substrate: persist v55.0.0 (rc7 registry; TraceRefused, InvalidReplaced, is_structurally_invisible) / verify v20.1.0 (invoke domain v2) / edge v41.0.0 (persist v55 adopt; durable_queue_depth read from the Edge). #759 already satisfied; #760 evidence rows re-pointed. From the earlier 0.5.225 opening: edge v40.0.12 (v40.0.11 never published either: its tag run failed on the dial-publish race, a fresh lane taken by a concurrent send before its dialer claimed it; v40.0.12 publishes lanes already claimed, unclaimed_ship_refused_total must stay 0) — a freshly claimed node admits its owner's rows at once: the claim and every local key write call edge's release_signer (CIRISEdge#858; the old node's rows had waited out a 1–6 h park after the claim, re-warned every round), with the park counters (rows_parked_on_signer, signer_releases, retry_suppressions, signer_park_evictions, refusal_memory_*/parked_on_signer_*) and recovered_links_total (#859: inbound links whose LinkEstablished the canonical dropped) served on /v1/federation/metrics; mock-LLM traces are also refused at the local capture doors (capture_event and the PyO3 capture), not only at the HTTP and relay ingest. From edge v40.0.11: (v40.0.10 never published: a gate failed and Codex found four reap fixes, including outbound use not refreshing the idle clock) — responder rounds and link-up timing by stage (responder_rounds_total, responder_link_up_seconds/_total), so an old peer whose links never complete a round shows as a signature; the HTTP ingest refuses mock-LLM batches (trace_mock_llm_refused, CIRISAgent#1244). From v40.0.10: the responder-side INBOUND idle reap (120 s, inbound_idle_bound; exempts transfers, LXMF serve, A/V holds and the identify window): the canonical is the responder for every other node's dial pool, and on 0.5.224 its inbound links grew ~95 per 5 min (agents on 0.5.223 never close theirs); inbound/outbound link gauges and closed-by-reason by direction, served on /v1/federation/metrics. Carries edge v40.0.9: openmls/libcrux panics contained in both MLS stacks and short-key KeyPackages refused at ingress (CIRISEdge#823; RUSTSEC-2026-0330/0331); the five #821 pool-reaper race fixes; the mobile wheels carry the PyO3 module (#824). persist v53.1.8 / verify v19.0.0 unchanged. 0.5.224 = 0.5.224 (OPENED): persist v53.1.8 / edge v40.0.8 / verify v19.0.0. persist v53.1.8: capacity_consent_stance (#1013) — the scorer's precheck and the emit gate ask ONE fold by principals (13 agents on the canonical were refused at emit every pass), plus read counters by door and fold, telemetry_snapshot() and cache_stats() (#1014). edge v40.0.8 = v40.0.7 (never released: cargo deny refused RUSTSEC-2026-0330/0331, now a dated ignore, CIRISEdge#822) + persist v53.1.8 — the dial pools reap links idle past 120 s and keep at most 4 idle per destination (CIRISEdge#819, the canonical's 1024 → 2048 link climb); edge telemetry P0 (#820): live transport counters on v40.0.x, link-pool gauges and closed-by-reason, round-duration and permit-wait histograms, all served on /v1/federation/metrics. /v1/federation/metrics names reticulum_link_count — the transport's established links, so a leviculum COMPLETION_MIRROR_OVER_ENVELOPE alarm (1024 → 2048 in 35 min on the canonical, CIRISEdge#819) can be told from a real link leak. 0.5.223 = 0.5.223 = A PEAK IS NOT A FLOOR: persist v53.1.7 / edge v40.0.6 / verify v19.0.0 — PASSED the bridge's 12-minute capped acceptance (copy of the live canonical home, genesis install at boot, 2 GiB, network none: max 439 MB, steady 85–275 MB, no OOM). edge v40.0.6 (release branch off v40.0.5): single-flight owner/consent memos and the sweep permit around the send-set, group-plane and recipient reads; persist v53.1.7: the trust-root walks bounded (the serve-tier walk on the post-genesis canonical 73 → 8 MB live, 2.4–3.8 → 0.23–0.44 s), group invitees, delegation granters, steward/owner node lists, V181. EARLIER IN THIS CUT: persist v53.1.5 / edge v40.0.5 (release branch off v40.0.4: the proactive push stops at its 256 KiB budget — every round toward a fresh or unreachable peer had fetched, decoded and trust-walked the whole advertised plane to ship ~17 rows, 8–15 min and 8–23 GB of churn per round, the capped runs' OOM) / verify v19.0.0 — the scorer pass at the canonical's shape decodes 17,100 rows / 48 MB instead of ~4.3 M / 1.63 GB (persist I538: the consent fold read the 2,401 rows about the node twice per agent), and the trace-summary read no longer sorts every payload. the 0.5.222 capped run on a copy of the canonical (2 GiB) read 152 MB live under 1.04 GB free-but-retained and OOMed; the node now returns retained heap to the kernel (malloc_trim every 30 s, CIRIS_MALLOC_TRIM_SECS, 0 = off; and after peering, once serving, after each scorer pass). 0.5.222 = 0.5.222 = THE CANONICAL CAN UPGRADE: persist v53.1.4 / edge v40.0.3 / verify v19.0.0 — a quorum-signed serve record replaces a held row the live quorum never signed (equal signed instant), and every federation_keys update keeps its whole scrub set; on 0.5.221 the canonical (whose own canonical-1 row kept only A1's scrub) booted PreGenesis{Community}. (OPENED) client floor 0.5.228 (the final-genesis ceremony UX, every 0.5.221 id localized). 0.5.221 = THE FINAL GENESIS, BAKED: persist v53.1.1 (the 2026-10-04 ceremony bundle sha256:db5e8e83…, the re-bake fix I497, the import door, two Codex P1s) / edge v40.0.2 / verify v19.0.0 — the release production nodes take to adopt the new root; roll the canonical first. A v3 bundle imports through persist's door and is accepted; GET /v1/trust-root/bundle serves the final genesis. Canonical-1's record keeps its July provenance on upgraded nodes (accepted, no practical effect). EARLIER IN THIS CUT: a self/family write on a node not registered as a personal device names the cause (`drive.device_class_not_personal`, persist v53 S1). Log retention ARCHIVES an aged-out file to .gz (verified, then the raw file goes) instead of deleting it — 0.5.220 (#721) erased every log older than 14 days on a node's first boot — and runs off the write path. 0.5.220 = THE FINAL GENESIS, ON PERSIST v53.0.1 / EDGE v40.0.1 / VERIFY v19.0.0 (FSD/FINAL_GENESIS.md; the maintainer: "the re-genesis — needs to be the final one"): one ceremony that all three accord holders sign mints the whole root — the canonical record(s), the labelled charter (successor set and per-holder recovery commitments), a quorum-scrubbed grant per canonical, the lifecycle row, the humanity-accord family record and the keyless ciris-canonical birth — in ONE v3 bundle that persist's assembler builds and verifies (`/v1/accord/final-genesis`: recovery-key, plan, sign, finish; instants stamped once; the recovery keys and successor set are the spares A2/B2/C2 from the original ceremony's record, genesis/accord_ceremony_keys.json); the 2-of-3 propose/cosign re-mint is retired (410). Every trust row is labelled (trust:charter / trust:confers / trust:accepts naming a head, one acceptance helper, re-authored once at boot); an old unlabelled portable bundle is refused at import. S1: an owned node registers its real device class (phone / laptop / server; CIRIS_DEVICE_CLASS; the desktop launcher sets laptop) and a misclassed occurrence is re-issued, so a person's own devices keep their self and family content; a per-epoch key not yet here is `awaiting_key` (409). No cross-version compatibility for NEW trust acceptance edges (the maintainer's ruling): roll the canonical and serve nodes first. Edge v40.0.1 (patch, same pins): six durability fixes from the #802 review — no repair loop on a fully held file, a withdrawn cohort file is hard-deleted, the sweep no longer stalls the puller, rooms past 4096 files are fully swept, and a self/family holding is never announced to an unknown peer (CC 5.2). (OPENED) ADOPTS EDGE v39.1.0 (persist v52.0.2 / verify v18.0.0): a pulled chunk DAG is not Stored or receipted until every chunk opens here, and a key arrival wakes the waiting pull (CIRISEdge#779); an unroutable peer backs off exponentially with jitter, kicks included, and resets on a learned path (CIRISEdge#794, the maintainer: the retry pacing was too fast). EARLIER IN THIS CUT: ADOPTS EDGE v38.1.1 / PERSIST v52.0.2 (verify v18.0.0 unchanged): edge instruments its anti-entropy round instead of holding a span guard across an await (CIRISEdge#790, with a gate that no guard is bound anywhere in its src/), so the 0.5.219 stopgap `src/span_leak_guard.rs` and its witness are deleted; persist v52.0.2 fixes the cohabitation abort (#705). A SEALED TRACE ROUNDS NOW (CIRISServer#719): lens-core's capture client seals and persists the agent's trace and stopped there, so the trace waited for the next 30 s anti-entropy tick — 6–33 s seal-to-stored on the production canonical under 0.5.219, measured from its `trace_events.admitted_at`; lens-core gains a host after-seal hook (`capture::client::set_on_sealed`) and the server installs the same coalesced replication kick it fires after a claim or a consent grant. A CONSENT IS A CLAIM (CIRISServer#713, CC 2.4 / CIRISConstitution#137): the row-type slot is closed at the five primitives plus two carriers, so the analyze consent, `POST /v1/auth/consent` and the watchlist enable are written as `scores` (never `consent` / `watchlist_config`); a node holding its analyze grant only under the old type re-authors it once as `scores`; the watchlist dimension gains its version segment (`watchlist:{id}:v1`) and the reader recognises an enable by dimension family, accepting the old type. THE CANONICAL STOPS SPINNING (production, 2026-10-02): the announced-relay refresh (#701) walked every identity with an occurrence — ~1,030 of 1,251 on the canonical were agents, nodes and itself — reading every row each had ever authored, and recomputed on every replication kick; passes took 8–86 s back to back and held ~1.5 of 2 cores reading the store at ~600 MB/s. Only a `user` key owns a node, so only persons are walked, and a nudge recomputes at most every 15 s (`NUDGE_FLOOR`; the 60 s periodic pass is unchanged). The trust-root re-mint is this cut (to come). PREVIOUS: 0.5.219 = 0.5.219 = THE CANONICAL STOPS CRASHING — a crash-fix cut (the maintainer, 2026-10-02: "Release a crash fix"); the trust-root re-mint moves to 0.5.220. ALSO IN THIS CUT, THE CLIENT FLOOR IS 0.5.226: `ciris-client` 0.5.226 carries every id the 0.5.218 surfaces emit (membership.*, custody.*, the claim, sessions, the contact flow, the drive and community planes), so 74 ids leave the guard's KNOWN_UNLOCALIZED debt list (93 -> 19) and the coverage ratchet drops 171 -> 19; 0.5.226 is also the first client that decodes the 202 `invited` of a community add. THE CRASH (CIRISEdge#790): edge's scheduler holds its `anti_entropy_round` span guard across an await, so round spans nest without bound — ~9,500 deep on the production canonical under 0.5.218, a stack overflow and exit 139 fifty minutes after deploy; the server's subscriber now disables that one callsite (`src/span_leak_guard.rs`), which makes the guard a no-op while every scheduler event still logs, until an edge release instruments the future instead. 0.5.218 = ADD A CONTACT BY CODE, AND A SECOND DEVICE THAT CATCHES UP (the maintainer's two flows). CONTACTS: `GET /v1/self/contact-code?nodes=` returns the person's v3 code (string + QR payload) naming the ANNOUNCED devices they choose, or none (resolved through the public directory) (#673); the device roster is public only for devices the person announced, per device (#655 ruling); `DELETE /v1/contacts/{key_id}` and `POST /v1/federation/peering/revoke` withdraw consent, signed by the person (#657); `GET /v1/admin/self` returns `owner_delegations` (#676). SECOND DEVICE: claim-remote links both devices at once, the device holding the pen re-wraps old self files for the new one, and `POST /v1/self/nodes/{node}/announce` announces another of my devices from the first (#678); replication runs 12 of 17 kinds, every kind routed or excluded by name (#646), so member additions, households and device revocations leave the node. FIXES: OAuth redirect parsed, same-origin path or exact loopback only (#672); a TPM request without a TPM refuses (#639); boot errors carry their whole cause chain (#586); a claimed node finds its consent peers (#601); reproposed chat commits are carried and the chat unopened reason is a token (#602). LADDERS: selffiles in CI (#622, #626), a `devices` scenario (second device joins; old self file opens; family rungs RED-EXPECTED on CIRISPersist#910/#916), ui-chat drives client 0.5.224 with nav derived from the client. Client floor 0.5.225. Known: history from BEFORE a device joined opens only for self files until CIRISPersist#916; restart re-admission waits on CIRISEdge#676 / CIRISPersist#911. WHERE EACH FILE IS (FSD/FILE_CUSTODY.md): `GET /v1/files/{id}/custody` names which of the person's devices hold a file, out of how many — persist's custody view (authorized as the bytes read) against edge's delivery receipts (CIRISEdge#738), each device `here`/`received`/`unknown`, every partial answer naming its `custody.*` reason (an inline file has no receipt until CIRISPersist#953; self/family copies are uncountable by design, CC 5.2); each drive row carries `custody: {devices_total, received_on}`; copy-to / remove-from a device is designed, not built. PREVIOUS: 0.5.217 = 0.5.217 = A FILE IS WHAT IT SAYS IT IS (CIRISServer#642/#641/#643/#644, for CIRISClient#77 and FSD/MEDIA_EDGE.md): the drive's write gate — every write door (upload, replace, move, notes) requires an RFC 6838 media type whose leading bytes agree with it (`drive.bad_media_type` 400, `drive.format_mismatch` 415 naming declared and sniffed — the same table as the client's RenderTier), and a filename cleaned per RFC 6266 §4.3 (path, control, bidi and zero-width characters removed; `drive.bad_filename` when nothing is left), so a name like `invoice\u202Etxt.exe` no longer replicates as written. The node states the PLAINTEXT digest of what it hands over (CC 5.3.2.5): `content_digest` on `GET /v1/files/{id}` and `/meta`, RFC 9530 `Repr-Digest` on a whole raw read; the edge pointer hash is renamed `at_rest_sha256` (it is the sealed blob's, never the plaintext's; `content_sha256` kept as a deprecated alias). `GET /v1/media/policy` publishes the node's render policy ahead of the ingest pipeline, with `renditions: false`. Notes say `here` like the drive (one word per fact; `drive::BYTE_STATES`). A digest SIGNED into the row, and renditions/placeholders, wait on CIRISEdge#638 and #614. PREVIOUS: 0.5.216 = 0.5.216 = ROSTERS AND FILES, END TO END (FSD/ROSTER_AND_DRIVE_CRUD.md): households (`/v1/families`: create, list, read, add/remove member, role, leave, dissolve, and an envelope → cosign → assemble flow for quorum families — closes CIRISServer#627), N-member communities and affiliations (`/v1/communities`, growth on persist v48's widening plane — CIRISServer#594), the owner's own devices (release a node, relabel a device, revoked devices listed; the self room uses edge's room-keyed handshake so a second device OPENS a file's bytes), and full drive CRUD (replace, rename, withdraw with a 410, move between circles, metadata, raw bytes with Range, a paged drive, notes edit/delete; the split-install viewer key fixed). Every roster read goes through the fold, enforced by a source gate. Known gaps, named in the FSD: a widened community member is refused reads until CIRISPersist#907; roster rows a PEER authors are signature-checked only until CIRISPersist#908; file rows are node-signed until CIRISEdge#675; family changes after creation do not replicate to a peer that already holds the family (CIRISPersist#910); subject take-back deferred. ALSO: ADOPTS EDGE v31.0.0 / PERSIST v48.0.0 (verify v16.1.0 unchanged; CIRISPersist#860/#905, CIRISEdge#672) — THE PROMOTION SWEEP SEES A CLAIMED MACHINE'S CONSENT: persist's `load_active_egress_grants` and the crossing's covering check take a grant by PRINCIPALS (the machine's own, or its bound steward's naming it in `for_key_id`), and V152 keys `consent_peer_set` by `for_key_id` — so an owned agent's sealed traces are promoted by its human's consent, the gate the production-shaped ladder had read as `offerable=0` since the owner's pen went live in 0.5.211. Also #860: a room's roster converges both ways (`CommunityMembershipWidening`, the 17th EnvelopeKind, appended — REPLICATION_POLICY_HASH / CONSENT_GRAMMAR_HASH / edge SERVE_ADVERTISE_POLICY_HASH re-pinned; ABI 5 unchanged); #672: the test-anchor block is persist's to mint (`mint_test_anchor` reproduces our block byte-for-byte; a `_MINTED_BY` line names the pair). EARLIER IN THIS CUT: ALSO ADOPTS EDGE v30.3.1 (CIRISEdge#671 → #673, persist v47.3.0 / verify v16.1.0 unchanged): a peer in no send set is minted a FirstContact recipient confined to exactly this node's allegiance facts (owner-binding, root acceptance, self-charter at federation audience), so a claimed agent Roots a canonical that consents to nobody on first contact — the server's `GET /v1/federation/allegiance` carry stays as the belt. THE ONE REMAINING GATE IS PERSIST'S (CIRISPersist#905): `promote_consented_backlog` loads grants the ENGINE key AUTHORED, and on every claimed install the human authors the grant (FOR the actor, since this cut — the covering door consents once per own key, node written last, and the actor is anchored at claim), so the sealed traces stay at `(self, local)`: the production-shaped ladder on the #636 merge read seal=3 trace_att=3 offerable=0. TAG HELD until persist folds the sweep over principals and edge pins it. PREVIOUSLY IN THIS CUT: THE HEAL LOOKED UP A KEY THAT EXISTS NOWHERE, AND THE TRACE PLANE RELIGHTS ON EDGE v30.3.0 — adopts edge v30.3.0 / persist v47.3.0 (verify v16.1.0 unchanged; CIRISEdge#659/#661/#662 → edge v30.2.0, CIRISPersist#901 → v47.3.0; CIRISServer#632): attribution is not trust (Attributed state; Rooted = a mutual VALID root through the owner-bindings), no baked production dial under a test root, a full responder inbox names its phase and never ends the driver, and `trust_root_valid` gains the holder-hardware leg (a valid root is as attested as its holders — the production seed's three accord holders carry real custody evidence; test roots carry persist's honest `SoftwareOnly_TEST` marker under a live test anchor). Server-side moves: `CallerScope::admits` takes the ROW's room as `cohort_target` (#893/#897), `files::in_room` is persist's gated reader door (caller named, `DrivePage`), `WithholdReason::RecipientNotRooted` classified as a verdict. THE OWNER ACCEPTS THE ROOT (CIRISServer#632 step 2 — the half the adoption alone does not deliver): edge's walk is `∃R ∈ roots_of(owner_of(N)) ∩ roots_of(owner_of(P))`, keyed on the OWNER, and the server had only ever written the NODE's `delegates_to(node → R)` (`accept_trust_root`, bootstrap default trust) — so under edge v30 no owned node was Rooted by any peer. `mesh_genesis::accept_trust_roots_as_owner` writes `delegates_to(owner → R, infra:attest, infra:serve)` at federation with the owner's pen (same envelope shape as the node's, idempotent, never the owner's own key), driven wherever the pen exists: at boot beside the baked-root install (the already-claimed fleet; the production canonical on its one boot with the operator's pen, which now heals the key record AND accepts the root), at both claim arms, and on trust-root import. Under a live TEST anchor the node's own key record carries persist's honest `SoftwareOnly_TEST` marker (a harness node that charters a root is a holder, and v47.3.0 reads an evidence-less holder as an invalid root; never in a production build). Witness: `tests/an_owner_accepts_the_roots_the_node_accepted.rs` — unowned → nothing; claimed → the owner's edge at federation, `trusted_roots_of(owner)` lists it, idempotent, and `trust_root_valid(owner, baked root).valid` by name. Fixture consequences of the persist major: the shared revocation fixture's self-charter signer now carries Layer-A evidence (persist's own remedy), and `trace_round_e2e`'s canonical ACCEPTS the root too — under edge v30 a pair is Rooted only when BOTH accept (it read "AGENT OFFERS 0 ref(s)" with `edge_exists: false` on the canonical). THE HEAL (CIRISServer#606): THE HEAL LOOKED UP A KEY THAT EXISTS NOWHERE (CIRISServer#606, found 2026-09-24 on the production canonical): `auth::ownership::rebind_owner_key_record` located the owner's row by `owner_signer.derived_key_id()`, but production's owner pen is built by `hardware_user_local_signer`, which hands `from_hardware_parts` the DERIVED id — so on that pen `key_id()` IS the registered id and `derived_key_id()` is `--`, a key registered nowhere (`identity.rs`, CIRISServer#597 §4: "every existing caller reads key_id()"; this door was the one that did not). The canonical is OWNED by the operator's portable fedID and runs the heal on every boot; with the pen placed on disk for one boot the pen resolved (three `responsible-user signer resolved` lines), the heal looked up the doubly-derived id, returned `Absent`, and the `Ok(_) => {}` arm at all three call sites (boot, claim, delivery) swallowed it — no line, row unchanged, and the row stays refused by every verify v15.2 peer. The gate `tests/owner_key_record_rebind.rs` was green throughout because its fixture pen is named by ALIAS, the one shape production never builds. FIX: the CALLER names the row (`owner_of`'s steward at boot/delivery, the binding's `responsible_user_key_id` at claim), the pen must be that key by `key_id()` or `derived_key_id()` and hold the row's pubkeys, and every outcome — `Rebound` / `Bound` / `Absent` / `Unbound{refusal}` / no pen — is logged by name at every site. Gate: a production-shaped pen (named by its derived id) must rebind, a stranger's pen is refused by name, and a source gate pins the key_id() convention. The one-off heal of the canonical's row is the same runbook re-run on this cut (CIRISServer#632 thread). PREVIOUS: 0.5.215 = EVERY IDENTITY THIS NODE IS, AND A FILE AT A CHOSEN COHORT. THE PUBLISH-OWN SET NOW CARRIES EVERY KEY THIS NODE IS (CIRISServer#629): on an actor/node split install (CC 3.4.7.3 Clause A) compose mints a NODE key, MOVES the owner-binding onto it and makes it the wire identity, and the node then attests its identity occurrences AND its `consent:replication:v1` grant with it — while the publish-own set held only `edge.signer_key_id()` (the actor key). Edge publishes a self-plane row only when its attester is in that set, so the node's OWN key record was never offered in a Key round: the far side admitted the actor key, never the node key, and then refused every row the node attested with "attesting_key_id … is not a registered federation key". Measured on the production canonical the same day — five agent keys admitted in one afternoon, ZERO node keys, ZERO rows of any dimension, no trace since 2026-09-18, and the grant authored on the agent and absent on the receiver. The second half of the same bug: `refresh_publish_own_set` asked `owner_of(edge.signer_key_id())`, but the binding had MOVED to the node key, so the owner never entered the set either (`owner_claimed=false` in the agent log). `own_key_ids(edge)` already answered "which keys are us" and two other call sites were gated on consulting it; this one was not. Both halves fixed and the existing gate extended to the publish-own seed — the gate itself had to be fixed twice (a fixed 4 KiB window stopped short of the code, then it matched the doc comment instead of the call), so it now reads whole function bodies with comments stripped. Same actor/node axis as the drive-auth finding below. ALSO: 0.5.215 = A FILE AT A CHOSEN COHORT, AND THE ROW REACHES MY OTHER DEVICE — adopts edge v29.5.0 (persist v46.3.1 / verify v16.1.0 unchanged; CIRISServer#626/#622, CIRISEdge#646): one door writes a file at any cohort and one drive lists everything this person can reach. `POST /v1/files {cohort: self|family|community}` seals through edge's `files::publish` at the ROOM's tier and crosses on a round-trip; `GET /v1/drive` lists it with each row's byte-state NAMED (`not_fetched` = "on another device", 409, vs `not_granted` = "this device holds no grant", 403 — different truths, and only a 200 is done); `GET /v1/files/{id}` opens the bytes. SELF-CHAT IS NOTE-TAKING: `POST /v1/notes` writes an UNNAMED `text/plain` row in the self room, `GET /v1/notes` reads them back, discriminated on type AND absent filename so an uploaded .txt is never reported as something the person wrote. THE SELF ROOM HAS A DRIVER: edge decides (`self_room::decide`), this host drives — `src/self_room_drive.rs` provisions the owner's content-KEM occurrence, then creates / publishes a KeyPackage / adds / removes on a named `loop_cadence` phase, under a watchdog. WHAT IS PROVEN, on a new two-node ladder (`harness/mesh-repro/scenarios/selffiles.sh` — ONE person, TWO devices): rooted=2 one_owner=1 roster=2 room=1 note=1 file=1 **mine_on_b=1** — the file is published, CROSSES, and the owner's second device LISTS it. WHAT IS NOT: `opened_on_b` (the BYTES) is blocked on CIRISEdge#656 — `decide` returns `PublishKeyPackage`/`Add` but edge can only author a KeyPackage or Welcome into a PAIR room, so those rows land in `chat:pair:v1:`, the creator holds the joiner's KeyPackage and cannot see it (`Added(0)` forever, room stuck at one member), and the second device reads `not_fetched`. The rung stays in the ladder and names that cause; it is promoted back to SUCCESS_STAGE when edge ships `*_attestation_in`. Three defects found by that ladder, none visible in the code containing them: (1) A LOOP CANNOT AUTHORIZE ITSELF WITH A SESSION — `owner_signer_capsule::acquire` reads a bearer, so a daemon could only pass `None` and refused `NotSignedIn` on EVERY tick forever, while the one arm that signs as the NODE (Create) worked, so a node created its self room, logged CREATED and looked healthy; `for_owned_node` is a second AUTHORIZATION (the node's owner binding, as `peer::owner_consent_pen` uses at boot), refusing an unclaimed node by name before opening any seed and verifying the resolved signer derives the key `owner_of` named. (2) THE HANDSHAKE IS NODE-ATTESTED — edge's `rows_in_room` resolves through `list_attestations_by(who)`, written for a chat room where a participant IS a person; a self room's participants are NODES while the person is one owner, so an owner-signed KeyPackage was invisible to the lookup. All four handshake builders take the node signer; the owner's pen stays the crossing ACTOR (the attester names the device, the actor names the authority, CC 3.3.6). (3) A HUNG TICK LOOKED LIKE A HEALTHY QUIET NODE — the loop logged on TRANSITION only, so a non-converging repeat and a wedged await were both silence; `Created`/`Added`/`Removed`/`Failed` now log every time and a tick that overruns 4x its period is abandoned by name. Gates: `tests/a_loop_has_no_bearer.rs`, `tests/the_self_room_handshake_is_node_attested.rs`. Harness: a `self` row needs no GRANT but `send_set_for`'s `nodes_of` is a DIRECTORY read, so the ladder PEERS the devices and waits for the ROSTER before sealing; and `grep -q` under `set -euo pipefail` reports FAILURE on a match (SIGPIPE to the writer, pipefail takes its 141) — it made three rungs read 0 against logs that plainly matched, fixed here and in run_embedded.sh / run_configured.sh. Community files get `comm_file`/`comm_file_on_b` on the chat ladder, the only ladder with two PEOPLE, measured before they gate. FAMILY files are NOT claimed: the consumer side is complete (edge FSD R0-R10, persist's `send_set_for` family arm) but nothing in production can charter a household family or admit a member — `family::create_family` has one caller (the keyless accord, via the trusted-local door) and `family::add_member` has zero (CIRISServer#627). PREVIOUS: 0.5.214 = A CLAIMED NODE READS ITS OWN CONFIG AGAIN — adopts edge v29.3.1 / persist v46.3.1 / verify v16.1.0 (CIRISServer#624 → CIRISPersist#888/#889, CIRISEdge#652): persist's read-side `self` gate compared a RESOLVED caller identity to a RAW row target, so after #873 a claimed node could not read its own `config:*` rows — `GET /v1/config` read `{}` and the second `POST /v1/federation/announce` 500'd on the agent's five-platform gate; v46.3.1 admits the caller's self-collective on both gate twins, and `tests/a_claimed_node_reads_its_own_config.rs` is the witness (red on v46.1.0, green here). Also: edge v29.2.0 `ScopeLifecycle::refresh_members` — `ensure_room_addresses` now REPAIRS a late-resolving member at the same epoch instead of only naming it (CIRISEdge#648); edge v29.3.0 / persist v46.3.0 `send_set_for` — a self row reaches the owner's second device (R2, CIRISPersist#884); verify v16.1.0 `create_federation_identity_in` (CIRISVerify#285) + the orphan `hw_token` stub removed (#283). Also in this cut (CIRISServer#618/#621, PR #620): the ASSOCIATE CUSTODY MATRIX — `POST /v1/self/associate` enrols this host from a hardware-held fed-ID with the two halves custodied independently (classical yubikey|tpm|software × pqc usb|tpm|software, composed through `HardwareRootedIdentity`), arguments validated before any token is touched, and both halves reported from `hardware_type()` rather than the request's words; plus HOME X IS HOME X — a `--home` now seals its own ML-DSA-65 half via verify v16.1.0's `create_federation_identity_in` + `preflight_keys_dir` (CIRISVerify#285), so the database, config, logs, Ed25519 seed AND post-quantum half are all scoped to that home; the 289-line relocation that stood in for the missing parameter is deleted. PREVIOUS: 0.5.213 = THE OWNER RECORD HEALS ITSELF — adopts edge v29.1.0 / persist v46.1.0 (verify v15.2.0 unchanged; CIRISServer#602 update, #606, #609, #612). The version names the NEXT TAG, not the last merge: 0.5.212 is the last tag, and everything since is this cut. Edge v25.2.0 = CIRISEdge#623 (inbound attribution never resolves to this node's own key; no responder is built for ourselves — the defence-in-depth half of #607) + persist v44.7.0 (#864: `Engine::rebind_key_record` — a same-key rebind is an admission). #606 uses that door: a registration record minted before persist #659 carries an envelope of {key_id} only, every verify v15.2.0 peer refuses it, and an OWNER's record rides the first identity round to every node the owner stewards — the production canonical held exactly one such row, the operator's portable identity stewarding nine nodes. auth::ownership::rebind_owner_key_record rebuilds the bound envelope from the row's OWN pubkeys, self-signs it with the owner's pen (refused if the pen does not hold those pubkeys), keeps every other member of the claim, and submits through the door; it runs wherever the owner's pen exists (boot via owner_consent_pen, the fold's author door, both claim-remote arms) and never for a hardware-custodied owner. delivery_status reports owner_key_record = bound | unbound | absent | unknown. Also in this cut: folded_health quiets host-pressure probes (a loaded CI runner made degraded_mode honestly true). THE ROUND SERVES AND THE BINDING CROSSES ON A ROUND-TRIP: the 0.5.212 canonical served zero rounds because edge's replication registry keyed coordinators by (peer, kind) with no role — a peer's inbound frames on a plane we also initiate to queued into the initiator's undrained channel and no responder was ever built (#612 / CIRISEdge#634, found by the in-process probe examples/probe_td_read.rs after three earlier layers: #607 self-prime, CIRISEdge#623 self-attribution, #609/CIRISEdge#624 first-contact carve-out). Edge v25.3.0 fixed the third layer (bootstrap attribution is an equality against the link's proven key, the announce rides the link); edge v26.0.0 fixes the fourth: two role-typed registry tables, CRPL frames carry the round id + direction (wire v3, 0x03 — refused by pre-v26 edges: a v26 node initiates only to v26 peers and answers every older initiator on the legacy path, so a mixed fleet shows one-directional timed_out until it crosses; ship every node on the same edge), and the drop that misdirected this RCA twice is now RouteOutcome::ReplyDropped{reason} + replication_reply_dropped_total / replication_routed_to_{responder,initiator}_total in the snapshot (now folded into GET /v1/federation/metrics `replication_round_routing` and delivery_status().replication_plane.round_routing). MEASURED on the first v26 chat-ladder run (2026-09-19, CIRISServer#612): both role tables build and drain, ReplyDropped 0, responders served on every plane — #634 is closed. On that edge the ladder was STILL red at `bound`, as it had been since v25.3.0 — the fifth layer: the bootstrap door compared a peer's FEDERATION key against the link's TRANSPORT identity (different keypairs on every node, bound by the SignedTransportDestination row), so a node's own record was refused on its own link, the link never attributed, and no Attestation/Community frame from it was admitted; node-a's owner-binding never reached node-b and the joiner withheld its KeyPackage (CIRISEdge#636, proven from the rows). Edge v26.1.0 fixes the door and adds a publish-side kick; the server kicks a coalesced round after the claim, the announce, each consent grant, the fold's author door and every chat row placed in a room (`compose::kick_replication`), so rows cross on a round-trip instead of the next 30 s tick. Persist v44.8.1 (#870): the two blob doors wrote the holds_bytes holder claim WITHOUT a signed_wire_index entry — advertised, never fetchable, so no peer ever learned a holder and every blob-backed body read not_fetched (the chat ladder's arrived=0 on every run since 0.5.207, mis-read as green by the verdict's high-water inference; arrived is REQUIRED now). Under that, three HOST doors the server had never set (found one per ladder run, each behind the last): edge's key-grant engine (ReplicationRuntimeConfig::engine — wraps stored, never projected), the scope-address lifecycle (CIRISEdge#499 — compose ARMED scope-native addressing and never DROVE it; install on Keyed with members = the roster's NODES, advance on epoch, seal on the `scope_seal` cadence) and the blob chunk source (EdgeBuilder::blob_chunk_source — the holder received the scoped BlobChunkFetch and dropped it). All three wired — and edge v27.0.0 (CIRISEdge#640) makes the half-wired node unconstructible: the three receive-side hooks are one type (`SealedContentWiring`: pull_sink ⇒ engine, revocations ⇒ engine) and a scope-native build is refused unless its chunk source declares `answers_scope()`; ours answers from the blob's community-DEK binding, then a referencing row through edge's own BlobMeaning. The last inch is persist's (CIRISPersist#873: `lookup_identity_for_occurrence` is LIMIT 1 with no order, so a node with its owner's login anchor resolves to ITSELF and `would_hold` refuses every community blob a non-author pulls as NotPartyTo); CIRISPersist#876 (the epoch's minter NAMED, not inferred from the author — the adopt wrote (community, person, epoch) while the seal and the key_grant set wrote (community, node, epoch), so authorize_viewer_by_tier missed on the minter member alone and every cross-node community body read NotGranted) is fixed in persist v46.0.0, and the chat ladder is GREEN: rooted=3 peered=2 pulling=1 contact=2 room=1 dark=1 bound=1 sent=1 arrived=1 hamburger=1 — A's bytes landed in B's transcript with their CEG identity intact. The ladder's `arrived` rung is REQUIRED (it had read 0 under `hamburger` since 0.5.207) and its verdict prints the cross-node timeline. Also: GET /v1/contacts rows carry the grant's envelope (#616). Tagged only on a green `bound` + `sent` + `arrived` + `served`. Persist v44.8.0 = the consent scope grammar (#866/#867): the envelope scope member is the normative carrier; one parser + one covering rule; the door refuses a malformed canonical token by name; retain: is honoured (ScopedStance.retain_until, expiry sweep emits consent:state:expired); the transfer grant's principle is read (only share/publish propagate). The server's bare analyze / share / view tokens parse unchanged; resolve_scoped_consent(_by_principals) keep their names. Client floor 0.5.203. SUBSTRATE: persist v52.0.1 / edge v38.1.0 / verify v18.0.0 — nobody joins a family or community without their own signed acceptance (invite → accept → widening, CIRISPersist#955 / CIRISConstitution#133), a quorum household's leave and dissolve replicate as amendments (CIRISPersist#956), every file is receiptable with the instant it was received (CIRISPersist#953), a family's files cross to the members' devices and its chunks are served under the family (edge `BlobMeaning::serve_scope`), a revocation names its subject by key digest (CIRISPersist#784), and a session claim carries its own signed lease (CIRISPersist#946); earlier in this cut on persist v51.3.0 / edge v37.1.0 / verify v18.0.0 — a file's name and type are sealed with its bytes and read through `drive::describe` (CIRISEdge#698), the person signs file rows (#675), the self-room consent sweep no longer widens a placed row (CIRISPersist#919), the dimension registry is strict (a gate checks every dimension the server spells), a withdrawn file stays withdrawn at the depth it was admitted under (#690), and a TPM-less host keeps its MLS state on disk under the software master; earlier in this cut on persist v49.0.0 / edge v32.1.0 / verify v17.1.0 (roster co-signatures and quorum proofs, the engine as blob evictor so a holds_bytes claim is retracted before its bytes go, Android generation custody credited as persist measures it (#339), the self-room Rejoin arm). FILE TRANSFER: a 25-file corpus over every supported type and size class, round-tripped on Linux/macOS/Windows and accepted by the first device; opening it on a second device waits on CIRISPersist#919 (the consent sweep widens self-room rows) and one size band on CIRISEdge#687. EVICTING A DEVICE is one signed act that replicates (CSD-037): `release` and `occurrence/revoke` converge on `self_devices::evict_device`, which withdraws the owner-binding AND writes the occurrence revocation through persist's SIGNED door (signed by the owner's pen, so the IdentityOccurrenceRevocation plane carries it) and kicks replication; until now a release revoked no occurrence and a revoke wrote the unsigned local-only row, so the #646 plane was routed but empty. Already-shared history stays readable by the evicted device (no DEK rotation, no re-encryption); only new content and new room epochs are withheld. CONSENT TO JOIN: adding a member to a family or community is refused (409 membership.consent_required) until the joiner can consent (CIRISPersist#955 / CIRISConstitution#133; FSD/MEMBERSHIP_INVITES.md) — direct adds, quorum adds, and founding rosters naming anyone but the founder. edition = "2021" # MSRV floor is set by the substrate: ciris-verify v5.2.0 requires 1.86 # (persist v6.0.1 is 1.83). Build with the higher of the two. diff --git a/evidence/CIRISServer.cc_impl.tsv b/evidence/CIRISServer.cc_impl.tsv index 749cb95c..04c5a16f 100644 --- a/evidence/CIRISServer.cc_impl.tsv +++ b/evidence/CIRISServer.cc_impl.tsv @@ -10,14 +10,14 @@ # # ─── RESOLVED ────────────────────────────────────────────────────────────────── decimal_id claim_id repo path#symbol crate@version -3.1 CLM-nsproc-dimension CIRISServer src/compose_policy.rs#polarity_for ciris-server@v0.5.239 -4.4.2 CLM-nsproc-aggregation-policy CIRISServer src/compose_policy.rs#polarity_for ciris-server@v0.5.239 -4.4.3.4.3 CLM-nsproc-cohort-scope CIRISServer src/graph_config.rs#set_config ciris-server@v0.5.239 -4.2.2.1 CLM-nsproc-attestation-evidence CIRISServer src/hardware_attestation.rs#admit_hardware_class_against_root ciris-server@v0.5.239 -2.6.6.1 CLM-location CIRISServer src/location.rs#mint_location_proof ciris-server@v0.5.239 -2.6.6 CLM-canonicalization-cell CIRISServer src/location.rs#mint_location_proof ciris-server@v0.5.239 -3.4.5 CLM-capacity-score CIRISServer src/scorer.rs#score_and_emit ciris-server@v0.5.239 -3.3.7 CLM-consent-directed CIRISServer src/peer.rs#emit_replication_consent ciris-server@v0.5.239 +3.1 CLM-nsproc-dimension CIRISServer src/compose_policy.rs#polarity_for ciris-server@v0.5.240 +4.4.2 CLM-nsproc-aggregation-policy CIRISServer src/compose_policy.rs#polarity_for ciris-server@v0.5.240 +4.4.3.4.3 CLM-nsproc-cohort-scope CIRISServer src/graph_config.rs#set_config ciris-server@v0.5.240 +4.2.2.1 CLM-nsproc-attestation-evidence CIRISServer src/hardware_attestation.rs#admit_hardware_class_against_root ciris-server@v0.5.240 +2.6.6.1 CLM-location CIRISServer src/location.rs#mint_location_proof ciris-server@v0.5.240 +2.6.6 CLM-canonicalization-cell CIRISServer src/location.rs#mint_location_proof ciris-server@v0.5.240 +3.4.5 CLM-capacity-score CIRISServer src/scorer.rs#score_and_emit ciris-server@v0.5.240 +3.3.7 CLM-consent-directed CIRISServer src/peer.rs#emit_replication_consent ciris-server@v0.5.240 # # ─── DECLARED GAPS ───────────────────────────────────────────────────────────── # Controls the Constitution attributes to this repo that are NOT enforced here. diff --git a/src/contacts_chat.rs b/src/contacts_chat.rs index 6c86ee18..b57720c0 100644 --- a/src/contacts_chat.rs +++ b/src/contacts_chat.rs @@ -2815,6 +2815,18 @@ async fn add_contact_for_owner(st: ChatState, owner: Owner, key_id: String) -> R ciris_edge::contact::ContactResolution::Known(_) => "federation", ciris_edge::contact::ContactResolution::ReadyFromCode { .. } => "direct", }; + // The nodes the code names (lightnet: the person's announced nodes). Their + // Key rows are what lets this node ADMIT the person's owner-binding once it + // arrives — persist refuses a binding whose attested node it has never met — + // so the resolve pass pulls them first (tracked: CIRISEdge#907). + let code_nodes: Vec = match &resolution { + ciris_edge::contact::ContactResolution::ReadyFromCode { admission, .. } => admission + .owned_nodes + .iter() + .map(|n| n.key_id.clone()) + .collect(), + ciris_edge::contact::ContactResolution::Known(_) => Vec::new(), + }; let key_id = match resolution { // Already in the directory: nothing to admit, go straight to consent. // Re-pasting a code for someone already known lands here too, which is @@ -3023,6 +3035,9 @@ async fn add_contact_for_owner(st: ChatState, owner: Owner, key_id: String) -> R // report unknown rather than asserting either answer. None => (false, 0), }; + // Every add, pasted once or completed by the pending pass: a person this + // node cannot resolve to a node is tracked until their owner-binding lands. + pending_contacts::track_until_resolved(&st, &owner, &key_id, &code_nodes).await; // Whether "not reachable" is offline or STRUCTURAL: an un-announced peer // has no node here to be reachable AT, and an un-announced self cannot be // delivered to however reachable the peer is. Said now, at the contact, @@ -5786,6 +5801,8 @@ pub(crate) mod pending_contacts { struct Unresolved { st: ChatState, owner: Owner, + /// The nodes the contact code named — pulled by key before the binding. + code_nodes: Vec, since: Instant, asked: u32, last_report: Option, @@ -5980,7 +5997,12 @@ pub(crate) mod pending_contacts { /// Track an added contact until their person resolves to a node here, and /// ask for their records now. A no-op when they already resolve. - pub(crate) async fn track_until_resolved(st: &ChatState, owner: &Owner, key_id: &str) { + pub(crate) async fn track_until_resolved( + st: &ChatState, + owner: &Owner, + key_id: &str, + code_nodes: &[String], + ) { if !nodes_of(st, key_id).await.is_empty() { return; } @@ -5994,6 +6016,7 @@ pub(crate) mod pending_contacts { Unresolved { st: st.clone(), owner: owner.clone(), + code_nodes: code_nodes.to_vec(), since: Instant::now(), asked: 0, last_report: None, @@ -6011,13 +6034,22 @@ pub(crate) mod pending_contacts { /// One pass over the unresolved contacts: drop the resolved and expired, /// re-ask for the rest, and say so out loud when one stalls. async fn resolve_pass() { - let due: Vec<(Key, ChatState, Owner, Instant)> = { + type ResolveDue = (Key, ChatState, Owner, Vec, Instant); + let due: Vec = { let t = unresolved().lock().expect("unresolved contacts lock"); t.iter() - .map(|(k, u)| (k.clone(), u.st.clone(), u.owner.clone(), u.since)) + .map(|(k, u)| { + ( + k.clone(), + u.st.clone(), + u.owner.clone(), + u.code_nodes.clone(), + u.since, + ) + }) .collect() }; - for (k, st, owner, since) in due { + for (k, st, owner, code_nodes, since) in due { let key_id = &k.1; if since.elapsed() > PENDING_TTL { unresolved().lock().expect("lock").remove(&k); @@ -6042,7 +6074,35 @@ pub(crate) mod pending_contacts { crate::compose::kick_replication("contact resolved to a node"); continue; } - let (peers, failed) = pull_person_records(&st, &owner, key_id).await; + // The node keys first: persist admits `delegates_to(owner → node)` only + // once it holds the node's Key row, so a binding that arrives before + // it is refused (`attested_key_id … resolves as neither`, the + // 2026-10-27 run) and only re-admitted when asked for again — which + // the person-records Pull below does every pass. + let mut missing_nodes = Vec::new(); + for node in &code_nodes { + let held = matches!( + st.engine + .federation_directory() + .lookup_public_key(node) + .await, + Ok(Some(_)) + ); + if !held { + missing_nodes.push(node.clone()); + let _ = pull_person_records(&st, &owner, node).await; + } + } + // Ask for the person's records only once the code's node keys are + // held: a binding refused for its unknown node is then held back by + // edge for a backoff window (CIRISEdge#544) before it is re-offered. + // A code naming a node no peer ever produces must not block forever, + // so after STALL_AFTER the person's records are asked for regardless. + let (peers, failed) = if missing_nodes.is_empty() || since.elapsed() >= STALL_AFTER { + pull_person_records(&st, &owner, key_id).await + } else { + (Vec::new(), Vec::new()) + }; let report = { let mut t = unresolved().lock().expect("lock"); t.get_mut(&k).and_then(|u| { @@ -6064,10 +6124,13 @@ pub(crate) mod pending_contacts { asked, pulled_from = ?peers, pull_failed = ?failed, + code_nodes = ?code_nodes, + code_node_keys_missing = ?missing_nodes, "contacts: CONTACT UNRESOLVED — the person is a contact but resolves to no node \ here, so a grant or message naming them has nowhere to go. This node asks each \ - peer for their records every pass. Where to look: (1) pulled_from=[] → no peer \ - to ask; (2) on the peer, `subject Pull refused … not publicly pullable` means \ + peer for their records every pass, and the code's nodes' keys first. Where to look: \ + (1) pulled_from=[] → no peer to ask; code_node_keys_missing non-empty → the \ + binding cannot be admitted until those keys land; (2) on the peer, `subject Pull refused … not publicly pullable` means \ it will not serve their owner-binding on request (tracked: CIRISEdge#907); \ (3) on the peer, is there a live federation `delegates_to` from this key_id to \ a node at all — if not, they have not announced" @@ -6134,7 +6197,6 @@ pub(crate) mod pending_contacts { } continue; } - let st_for_resolve = st.clone(); let resp = add_contact_for_owner(st, owner.clone(), k.1.clone()).await; let status = resp.status(); if status == axum::http::StatusCode::ACCEPTED { @@ -6143,7 +6205,6 @@ pub(crate) mod pending_contacts { table().lock().expect("lock").remove(&k); if status.is_success() { tracing::info!(owner = %owner.key_id, key_id, waited_secs = since.elapsed().as_secs(), asked, "contacts: pending contact COMPLETED — the key arrived and matched the code"); - track_until_resolved(&st_for_resolve, &owner, &key_id).await; } else { tracing::warn!(owner = %owner.key_id, key_id, status = status.as_u16(), "contacts: pending contact discarded — the add path refused it once the key arrived (see the refusal logged just above)"); } diff --git a/tests/contact_key_pull_e2e.rs b/tests/contact_key_pull_e2e.rs index 786ac480..5a12854a 100644 --- a/tests/contact_key_pull_e2e.rs +++ b/tests/contact_key_pull_e2e.rs @@ -567,7 +567,9 @@ async fn a_contacts_announced_owner_binding_arrives_on_a_subject_pull() { carry_key(&canonical, &asker.engine, &asker.key_id).await; carry_key(&asker, &canonical.engine, &canonical.key_id).await; carry_key(&asker, &canonical.engine, &contact).await; - carry_key(&asker, &canonical.engine, &contact_node).await; + // NOT the contact's node key: the 2026-10-27 run's asker held the person's + // key (the add) but not their node's, and persist refused the binding. The + // node is named by the contact code (`owned_nodes`); its key is pulled first. accept_shared_root(&[&asker, &canonical], "resolve-root", &canonical.key_id).await; assert!( announced_nodes_of(&asker.engine, &contact) @@ -615,14 +617,48 @@ async fn a_contacts_announced_owner_binding_arrives_on_a_subject_pull() { EnvelopeKind::Attestation, SessionRole::Responder, Arc::new(DirectoryStateAdapter::new(canon_bridge.clone()).with_peer(asker.key_id.clone())), - Arc::new(MutableDirectoryStateAdapter::new(canon_bridge)), + Arc::new(MutableDirectoryStateAdapter::new(canon_bridge.clone())), ); - // What `pull_subject_testimony(canonical, contact)` sends on this plane. + // The resolve pass: the code's node key first (Key plane, public), then the + // person's records again. + let (ka_tx, mut ka_rx) = tokio::sync::mpsc::channel::>(64); + let (kc_tx, mut kc_rx) = tokio::sync::mpsc::channel::>(64); + let asker_key_coord = ReplicationCoordinator::new( + Arc::new(Loopback { to_peer: kc_tx }), + canonical.key_id.clone(), + EnvelopeKind::Key, + SessionRole::Initiator, + Arc::new( + DirectoryStateAdapter::new(asker_bridge.clone()).with_peer(canonical.key_id.clone()), + ), + Arc::new(MutableDirectoryStateAdapter::new(asker_bridge.clone())), + ); + let canon_key_coord = ReplicationCoordinator::new( + Arc::new(Loopback { to_peer: ka_tx }), + asker.key_id.clone(), + EnvelopeKind::Key, + SessionRole::Responder, + Arc::new(DirectoryStateAdapter::new(canon_bridge.clone()).with_peer(asker.key_id.clone())), + Arc::new(MutableDirectoryStateAdapter::new(canon_bridge.clone())), + ); + asker_key_coord + .start_pull(&contact_node) + .await + .expect("Key subject Pull for the code's node sends"); + pump(&asker_key_coord, &mut ka_rx, &canon_key_coord, &mut kc_rx).await; + assert!( + asker_dir_holds(&asker, &contact_node).await, + "the code's node key arrived on the public Key plane" + ); + // Then what `pull_subject_testimony(canonical, contact)` sends on the + // Attestation plane. Asked BEFORE the node key, persist refuses the binding + // (`attested_key_id … resolves as neither` — the 2026-10-27 deadlock) and + // edge then backs off re-asking it (CIRISEdge#544). asker_coord .start_pull(&contact) .await - .expect("Attestation subject Pull sends"); + .expect("the person-records Pull"); pump(&asker_coord, &mut a_rx, &canon_coord, &mut c_rx).await; assert!( @@ -634,3 +670,13 @@ async fn a_contacts_announced_owner_binding_arrives_on_a_subject_pull() { directed grant naming them has a node to reach (tracked: CIRISEdge#907)" ); } + +async fn asker_dir_holds(n: &Node, key_id: &str) -> bool { + matches!( + n.engine + .federation_directory() + .lookup_public_key(key_id) + .await, + Ok(Some(_)) + ) +}