Repository navigation
release(40.0.0): persist v53 + CIRISVerify 19 — one audience resolver, stream-epoch file keys, durability at every tier, device classes, build standing on both planes - #802
Conversation
Pins both ciris-persist entries to rev b68f694a88fc9e5973fb706584ee176304c0c4c7 (head of CIRISPersist refs/heads/v53). The crate at that rev still reports version 52.0.1 (persist has not bumped Cargo.toml on the v53 branch), so the requirement stays `version = "52"` and pyproject is `ciris-persist>=52.0.1,<53` (the pin-skew check resolves the rev's version). One ciris-verify-core: persist at this rev still pins CIRISVerify v18.0.0, so edge's verify pins and src/bundle_gate.rs are unchanged (CIRISEdge#786 waits on verify 19). Adaptations (all test fixtures; no production call site moved): - CIRISPersist#973 "bundle only": a new unlabelled delegates_to is no charter and no acceptance. Fixture charters now carry `dimension: TRUST_CHARTER_DIMENSION`; acceptance edges are built from persist's `canonical_community::acceptance_edge_envelope` (bridge.rs seed_acceptance_edge) or carry TRUST_ACCEPTS_DIMENSION (mesh_config.rs, first_contact_ladder_659.rs, relay_roster_752.rs). Grants stay unlabelled: an unlabelled grant still confers. - CIRISPersist#973 witness_quorum: DEFAULT_WITNESS_QUORUM removed; fixture charters declare witness_quorum 0 (silence = 0 = witnessed mode off; a declared 1 is refused at the charter door). rooted_with_uncached only composes persist's trusted_roots_of / trust_root_valid, which apply the labelling rule themselves; no edge change. No pinned constant moved: *_hash_pinned and field_conformance are green at the old values, and edge pins no DirectoryOp digest (ABI still 7). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Gxb3KeL2LhGdoiAuZmknNm
…asks both standing planes (#786), not for release Pins both ciris-persist entries to rev c9d070af5ce32a98e4691cb097f8bfd55430661d (CIRISPersist v53 branch: "pin CIRISVerify v19.0.0 on all seven crate pins"). The crate still reports 52.0.1, so the requirement stays `version = "52"` and pyproject stays `ciris-persist>=52.0.1,<53`. Edge's three verify pins (ciris-keyring, ciris-crypto, ciris-verify-core) move to tag v19.0.0, as persist pins them; `cargo tree -i ciris-verify-core` shows one, v19.0.0. CIRISEdge#786, CC 3.1.2.1 (rc6 22ea349): a reader asks BOTH standing planes before it finds a pipeline without standing. `bundle_gate::pipeline_blessing` asks persist's capability walk `capability_roots_to_trusted_root(dir, reader, pipeline, "infra:attest")` first (Delegation / FamilyQuorum map to `PipelineBlessing::conferred`; the walk's AccordCoScrub arm maps to `accord_role`, as CIRISRegistry 18a138c does), and only if it finds nothing `admission::is_infra_attest_effective` (`PipelineBlessing::accord_role`). Neither -> `BundleGateRefusal::PipelineWithoutStanding`. The pre-19 `(pipeline_record, accord_anchors)` path is gone, with `NoAccordAnchors` and `MalformedPipelineRecord`: verify's co-scrub copy could not see a quorum role withdrawal. The walk is reader-relative, so `RootingDirectory::verify_peer_build_bundle` and `gated_save_provenance` take the reader's key id; the reticulum transport passes its `local_key_id` (AnnounceCtx gains it; EventCtx already had it). `bundle_pipeline_key_id` reads the manifest's signed `row.attesting_key_id` (verify 19 has no top-level attester in the envelope). Verify 19 producer changes, test fixtures only: `sign_build_manifest_contribution` takes `manifest_size` and a DateTime; `produce_build_attestation_bundle` takes `presents: PresentedBuild::SelfVerify` and a DateTime. No production edge code called them, nor `to_attestation_entry`, nor matched exhaustively on `BundleRejection` / `ManifestRejection`. Witnesses (bundle_gate lib tests): (a) walk-conferred plain pipeline, reader accepts root-r -> Verified with Conferred{Delegation}; the same rows read by a stranger reader -> refused; (b) ceremony co-scrub only, walk None -> Verified with AccordRole; (c) neither plane -> PipelineWithoutStanding, the save downgrades; (d) co-scrubbed pipeline whose infra:attest the quorum withdrew: the stored row and accord anchors the pre-19 path verified are unchanged, persist's effective read says no -> refused. No pinned constant moved (hash_pinned / field_conformance / policy_hash green). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Gxb3KeL2LhGdoiAuZmknNm
…e v3), not for release Moves the persist pin c9d070af -> ee5e2106 (crate 52.0.2, which carries v52.0.2's CIRISServer#705 pool-connector fix) and the pyproject floor to ciris-persist>=52.0.2,<53. Verify stays at v19.0.0 (one ciris-verify-core). Compile-forced adaptations only: - R2a (V174): Community/Family literals carry empty prev_head_digest and charter_digest (founding records and fixtures; chat's builder only founds). - R1 (CC 3.2 T3): pre-rotation commitments bind CommittedKey (key id and both public keys); fixtures commit to the successor's registered record or to persist's test_committed_key. - #969: ChunkRef.epoch is None in clear manifests and fixtures; KeyGrantAxis::Stream wakes nothing yet (CIRISEdge#797 adds the wake). Tests whose premise #969 changes (an epoch terminator chunk per stream, stream-epoch grants, v4 DAGs the puller does not adopt yet) are ignored against CIRISEdge#797: 4 in chunk_grants_779_tests, 9 integration tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Gxb3KeL2LhGdoiAuZmknNm
…he holds_bytes carrier exactly CIRISEdge#797 (persist v53 at ee5e2106, CIRISPersist#969 — one DEK per (stream, epoch), manifest v4, a zero-length terminator per epoch at seq 2^62 + epoch): - key_grant:stream:v1 wakes DAGs parked on a stream epoch (PullSink::key_grant_admitted -> KeyWaits::wake_stream, Awaiting::Stream). Routing and emission were already axis-generic (prefix router, emit_pending_key_grants / dirty_axes). - The puller adopts each chunk at its manifest epoch (v4), or the one-shot label (v2); an adopt batch never crosses an epoch. - Readiness: #787's per-chunk get_at_rest_grant loop is replaced by ONE Engine::sealed_dag_readiness call for v2 and v4 (persist I314c); the pull parks on exactly what `missing` names. - blob_chunk_key_not_yet_granted is the wait, never NotGranted: the read side maps it to GroupContentError::ChunkKeyPending / UnopenedReason::AwaitingKey (is_pending), naming the chunk and the key; a promote that returns it parks as DagAwaitingKey. The edge-side refused_chunk probe (wrong under v4: chunk rows hold no wrap) is gone. - Terminators: chunk counts, layout (empty extents at the file's end), FileRow::chunks (skips them), the serve membership set (members) and receipt K (= persist's STH tree size, terminators included) checked. CIRISEdge#791: is_holds_bytes_type matches ^holds_bytes:sha256:[0-9a-f]{8}$ whole and byte-exact at the three meaning.rs sites; 16-hex fixtures fixed. The 13 #797-ignored tests run un-ignored. New witnesses: a two-epoch v4 DAG (test-only producer epoch roll) pulled with epoch 1's set late, woken by it, read and range-read across its terminators, one receipt with K = 7; the pending-key refusal; the matcher; the pure adopt/park rules. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Gxb3KeL2LhGdoiAuZmknNm
…evice_class (#799) provision_engine_occurrence compared only the content-KEM pubkeys, so a node first provisioned `server` (every node the server ever provisioned, phones and desktops included) stayed `server` forever; persist S1 derives the replication audience from device_class, and a server-class node gets no self/family content. - Same pubkeys, another class: re-sign and publish under the new class (same keys, newer asserted_at, the stored valid_until carried) and report Provisioned::Reclassed { from, to }. persist's signed upsert is last-signed-wins on device_class (sqlite.rs ON CONFLICT ... WHERE excluded.asserted_at > stored), on this node and on a peer. - Drifted (other keys) is still never touched. - device_class is checked against persist's closed set (types::device_class::is_valid) at both doors before anything is written. - Provisioned is #[non_exhaustive] (MAJOR, in the v53 cut). - ensure_content_occurrence had the same gap: an unsigned row is re-written under the new class (every other column carried); a signed row is refused by name, since the local door never mutates one and only its signer can re-issue it. Witnesses (lib): server -> phone is Reclassed on the node's plane and on a peer's, a second call is AlreadyCurrent with no asserted_at churn; the expiry survives; the local door reclasses, never writes drift, refuses a signed row and an unknown class. Each fails with its hunk reverted. Closes #799 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Gxb3KeL2LhGdoiAuZmknNm
…the signed plane (#799) d547bbc reclassed ensure_content_occurrence's rows through the trusted-local door only. On an actor/node split (CIRISServer provision_with) the wire key's occurrence is usually SIGNED: the identity publishes it at login (self_at_login), and the local door never mutates a signed row. So the split path would have refused, with no way to move the class. - ensure_published_content_occurrence(backend, signer, identity, class, enc): the signed door for an occurrence keyed by a key other than the engine's (occurrence = the signer's derived key; persist's publish_signed_content_only_occurrence, lifted by its owner binding). provision_engine_occurrence's rules exactly: Created publishes; AlreadyCurrent heals only an off-plane row; Reclassed re-signs under the new class, valid_until and hardware_attestation carried; Drifted is never touched. - Both signed doors now share one plan (publish_plan), so the engine path and the wire path cannot decide differently. - ensure_content_occurrence's refusal of a signed row names that door. Witness (lib): a wire occurrence on the plane as `server` with an expiry is reclassed to `phone` by the wire key's signer (same keys, expiry carried, newer signature, a peer admits it); a second call re-signs nothing; other keys are Drifted and untouched; the local door refuses the signed row and points here. Each rule fails the witness with its hunk reverted. Refs #799 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Gxb3KeL2LhGdoiAuZmknNm
…rve gate; membership planes gated (#761) Persist pin ee5e2106 → 8f9d2d61 (v53 S1, CIRISPersist#963; crate still 52.0.2). Re-pins REPLICATION_POLICY_HASH (1860451c…, KindPolicy.audience) and CONSENT_GRAMMAR_HASH (4d473eac…, per-node `cohorts`). - Attestation audience gate (advertise + fetch twin): the row half is persist's `may_receive(peer, row)`, memoized per sweep on the fields it reads; the #955/#756 membership-ceremony arms stay ORed on top. - Group records and the five membership planes (revocations, widenings, listing): per row, per peer, persist's `may_receive_group_plane` on the advertise and the fetch twin, over one (scope, group, named) reader (`replication::group_plane`); an unbound requester gets only public groups' rows. Retires the `public_group` stand-in and the held-invite memo. - A `key_grant:` set is no longer first-party to a device it names: fetch and subject-Pull ask `may_receive` (persist I397). - SERVE_ADVERTISE_POLICY_HASH re-pinned e3070d53… → e7b1ba86…. - Fixtures: device classes match what each models (personal devices that hold self/family content are phone/laptop; hosts and forwarders stay server); runtime-node fixtures publish their own signed occurrence. - Witnesses: #799 reclass under S1, a membership-plane row reaching a live invitee and not an outsider, a server-class owned node getting rooms but not self/family, advertise/fetch agreement; the #762 public-group test now uses an authorized infrastructure community. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Gxb3KeL2LhGdoiAuZmknNm
…lassed by the owner (ReclassNeedsSigner) Reverts 6730bcb. Occurrence rows are owner-signed; a node re-signing its own occurrence with its wire key is a trust change persist and the server both rejected. A signed row whose class differs is left for its signer to re-issue (`Provisioned::ReclassNeedsSigner`, next commit). The S1 fixture edits that rode on 6730bcb's files are kept (the device class parameter and the #799 S1 witness in chunk_grants_779_tests). Refs #799 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Gxb3KeL2LhGdoiAuZmknNm
…sSigner, not an error
`ensure_content_occurrence` refused a SIGNED row with the same keys under
another `device_class` with Err. The server's split-node wire-key
occurrences are signed rows whose signer is the OWNER (the `self_at_login`
fold), and the server provisions on every self-room tick and before
drive/chat writes, so the Err meant the node never provisioned and every
self write was refused. It now returns the non-error
`Provisioned::ReclassNeedsSigner { from, to }`, leaves the row untouched
(only its signer re-issues it), and logs at WARN once per
(identity, occurrence, from, to). The witness
`the_local_door_reclasses_and_never_touches_drift_799` asserts the variant
and that the active and signed rows are unchanged.
Refs #799
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gxb3KeL2LhGdoiAuZmknNm
…legacy v2 writer) Persist 8f9d2d61 → 32fe46f9; the crate is still 52.0.2 and both pinned hashes are unchanged. What it forces on edge: - S3 (#963, CC 6.1.5.3): `projection_for(FountainContent, self | family)` is now `Cohort`, not `SelfOwn` (self/family bytes hold-and-forward within their audience). `swarm::scope` pins move with it; an outsider is still withheld (`PeerNotInRoster`, now `projection: "cohort"`). - S2 (#942, CC 3.1.3.3, `custody:ack:v1`): `blob_custody.copies_observable` is true at every tier; the e2e custody test and the `FileRow::custody` doc move with it. - I397b–d: a device re-classed into its owner's self audience is a newcomer to the self keys the receiving node holds. The #799 S1 witness now also asserts the self file published while the phone was labelled `server` opens on it after the reclass (the gap reported on the S1 adoption is closed). Refs #761, #799 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Gxb3KeL2LhGdoiAuZmknNm
…R2c backdated-lag fix) Persist 32fe46f9 → 0df60dcf, four commits: `write_legacy_v2_dag` takes the caller AAD a v52 writer bound (edge's `content_aad`), and R2c's backdated roster row no longer escapes the lag (no edge surface). Crate still 52.0.2; REPLICATION_POLICY_HASH and CONSENT_GRAMMAR_HASH unchanged. Refs #797 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Gxb3KeL2LhGdoiAuZmknNm
…ther device (#797) End-to-end witness over persist's one v2 writer (`write_legacy_v2_dag`, test-anchor): A (alice's laptop) writes a v52-shape per-chunk-keyed file, manifest and chunks sealed under edge's `content_aad` of the row that names it, and emits its key-grant sets. B (her phone, personal class: under S1 a server-class node gets no self keys) pulls through edge's DAG path with chunk 0's content grant held back: the pull adopts the manifest and every chunk and parks on that grant; the retry ladder runs out; the grant's arrival wakes the pull, which promotes. On B the manifest is v2 with no chunk epochs, readiness reads readable on content keys, and whole and range reads equal the plaintext. Fails without edge's v2 handling: with `awaiting_of` not parking a v2 DAG on its chunks' content grants, the grant never wakes the pull ("the parked v2 DAG was never re-pulled"). Refs #797 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Gxb3KeL2LhGdoiAuZmknNm
…membership ceremony; pin persist 8fcbeb9e Persist 8fcbeb9e closes the S1 gaps: may_receive routes a membership:proposal to every node whose principals include the invitee, an acceptance/decline to the proposer's nodes (through the held proposal it answers), and every stage to the group's membership-plane audience; affiliations is a room's membership plane (live_invitees_of included); is_public_group accepts a Rooted keyless trust-root community. Edge's own ORed checks (peer_is_proposal_invitee, peer_is_reply_proposer, proposal_invites_peer and their owner/principal/proposal memos) are removed from audience_withholds. The first-contact carve stays, as the TRANSPORT half only: under Reach::FirstContact a ceremony row at a family/community audience that may_receive admits as RefersTo crosses the reach and skips the Rooted floor; who it is addressed to is persist's. MayReceiveKey now keys on references_attestation_id, which persist's answer arm reads. SERVE_ADVERTISE_POLICY_HASH re-pinned e7b1ba86 -> 68c5298b (the Attestation cell's text). REPLICATION_POLICY_HASH and CONSENT_GRAMMAR_HASH unchanged at 8fcbeb9e. Witnesses: an affiliations-placed proposal makes a live invitee of the room; a Rooted keyless ciris-canonical record reaches a stranger and an unbound requester while an unrooted squat does not. Refs #761 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Gxb3KeL2LhGdoiAuZmknNm
…s (CC 6.1.5.3)
Persist v53 S2/S3 (custody:ack:v1, FountainContent projecting Cohort at
self/family, durability_mode / durability_deficit) adopted on edge's
existing paths:
- A completed self/family pull (inline or DAG, the receipt hook's two
sites) files this node's `here`. For a chunk DAG `here` means the
manifest and every chunk; edge checks that itself through the
readiness door under the row's AAD, because persist's
custody_ack_input_for opens the manifest without it and refuses an
edge-sealed DAG as an AEAD mismatch. The row is persist's
custody_ack_envelope, emitted by the engine's self-signing door, and it
replicates through the existing audience gate (may_receive) only.
- BlobPuller::durability_sweep (on its own cadence, default 15 min):
over each principal's self room and active families (files::in_room),
persist's deficit per file (content_audience + deficit_over, read from
the directory, so a device without the bytes still computes it);
re-files a lapsing `here` (24 h), corrects a lost copy to `none`, and
returns the repairs rarest first; the run loop dispatches them.
- The self/family holder rung adds the deficit's live `here` nodes to the
author's, so a repair pulls from any cohort holder.
- A promoted sealed DAG missing a chunk is no longer AlreadyHeld: the
walk re-fetches the chunks the readiness door names and reports Stored
once whole. persist 8fcbeb9e refuses re-adopting the identical
(seq, sha) at the surviving stream position ("seq N already exists"),
so the repair stops there today; the full witness is ignored with that
reason.
- Fountain holdings: self/family announcements answer from persist's
resolve_projection_recipients(FountainContent, ..., group key) instead
of the address table; the same answer admits an inbound holding claim
(signer and peer_id), so a claim from outside the cohort reaches
neither the converged view nor the converger. A self/family content
with an audience under N + K = 26 is Full: never ejected, RepairNeeded
below the audience size; the tuple governs at 26 and above.
Table-roster witnesses that used a family group move to a community
group (the tier that still reads the table).
Refs #763
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gxb3KeL2LhGdoiAuZmknNm
…'s AAD; lost chunks and whole lost files repair (#763); persist v53.0.0 Pin persist at tag v53.0.0 (39687ec9; source identical to 973de4f9), crate 53, pyproject floor ciris-persist>=53,<54. - file_custody calls Engine::put_custody_ack with the row's content_aad and the row's cohort; edge's own completeness check, custody_ack_envelope build and emit_attestation_self are gone. Persist's door refuses a DAG not held whole (custody_ack_here_dag_incomplete) or a manifest that does not open under the data (custody_ack_here_seal_did_not_open). - A lost chunk is repaired: persist re-adopts the identical (seq, sha) at its kept position. The refusal-asserting witness now expects Stored, and a different chunk at that position is still refused and stores nothing. a_chunk_repair_refiles_here_for_the_whole_dag_763 is un-ignored and goes through the sweep: none, repair, Stored, a new `here`, byte-identical. - Whole-file loss: the sweep reports none and repairs the file rarest first, and it reads whole with no key_grant set re-applied (eviction keeps the at-rest grants). - A withdrawn file reads Withdrawn for a grant-holder and the sweep does not repair it. A device that filed `here` still reads it: persist's tombstone fold counts the custody report as a live binding (persist gap, pinned by name). - A wrong-AAD read of a v4 DAG, whole and by range, is SealMismatch. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Gxb3KeL2LhGdoiAuZmknNm
…withdrawn file is refused to peers and never reported `here` (#763) CC 2.3 at the bytes plane. A device's custody:ack:v1 `here` report cites the blob in evidence_refs. Edge counted it as a reference, so after the owner withdrew a file, the device's own report kept the revocation register Live and PersistBlobChunkSource::read_chunk served the withdrawn DAG's chunks to peers. The register is the only chunk guard until persist links chunks to their manifest (CIRISPersist#979). - meaning: is_custody_report (persist's CUSTODY_ACK_DIMENSION) and is_possession_row (holds_bytes or custody report). BlobMeaning::project refuses a custody report as PossessionIsNotMeaning; both referenced_shas return nothing for one. - revocation: observe, the withdraws target and the binding-list completion skip possession rows (persist's v53.0.0 binding list includes custody reports). - durability::file_custody refuses `here` for a withdrawn or recanted row (files::row_lifecycle, the drive's composer predicate), on the post-pull report and the daily re-file alike. - blob_serve_refusals["withdrawn"] (observability::BLOB_SERVE_REFUSED_WITHDRAWN) counts serves refused Withdrawn at the BlobChunkFetch responder. - Every production PersistBlobChunkSource (edge_node) arms the register. Refs #771 (whole-DAG eviction on withdrawal stays open). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Gxb3KeL2LhGdoiAuZmknNm
…anding at every use (#793); BundleGateRefusal is non_exhaustive; #786 witness (d) runs the pre-19 path PeerBundleStore cached a VERIFIED verdict by the bundle's bytes, so a later withdrawal of the pipeline's infra:attest, or the reader no longer accepting the root, never reached a cached peer: its Rooted saves stayed Rooted until it re-registered different bytes. Standing is not a property of the bytes (CC 3.1.2.1). - The cache keeps the bytes' verification and the blessing it ran under, for the reader that asked. At each use, gated_save_provenance re-resolves the standing through the new RootingDirectory::pipeline_standing (pipeline_blessing, both planes): the same blessing is Rooted, none is Advisory and drops the entry, and a different one re-verifies. note_verified takes the reader and the verdict. - BundleGateRefusal is #[non_exhaustive]. There are no exhaustive matches outside its own Display. - #786 witness (d): the withdrawn-pipeline test now runs verify 18's co-scrub standing check, reconstructed from verify 19's unchanged public primitives, over the same directory rows. It roots the withdrawn pipeline and refuses a plain one (control), while neither CC 3.1.2.1 plane admits it and the gate refuses. Closes #793 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Gxb3KeL2LhGdoiAuZmknNm
… wheel floor >=53.0.1; B reads a withdrawn file as Withdrawn (#763) persist v53.0.1 (85fa53a4) fixes the shared content-binding predicate: a custody:ack:v1 row never binds content, so a device that filed `here` for a file no longer keeps it Live after the file is withdrawn. The withdrawn-file witness's B leg flips from pinning the v53.0.0 leak (B reads Ok; its own ack is a binding) to asserting the fix: B's ack is not a binding, the fold is Withdrawn, and B's read is Withdrawn like A's. The wheel floor rises to 53.0.1 so edge never runs on the leaky 53.0.0. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011ksniaWCvWoUwFQHRG3HQL
…, stream-epoch file keys, durability at every tier, device classes that follow the hardware, build standing on both planes MAJOR from v39.1.0. persist tag = "v53.0.1" (85fa53a4), version 53, wheel floor >=53.0.1,<54; CIRISVerify v19.0.0. REPLICATION_POLICY_HASH 1860451c…, CONSENT_GRAMMAR_HASH 4d473eac…, SERVE_ADVERTISE_POLICY_HASH 68c5298b…. Evidence TSV at ciris-edge@v40.0.0. Notes: docs/RELEASE_NOTES.md § v40.0.0. Closes #761, #763, #786, #791, #793, #797, #799 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011ksniaWCvWoUwFQHRG3HQL
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 56e5844b5e
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| if !self | ||
| .claim_admission(&claim.content_id, &claim.peer_id) | ||
| .await | ||
| .is_announced() |
There was a problem hiding this comment.
Enforce cohort claim admission without a scope table
When the runtime is created through FountainSwarmRuntime::start or default SwarmRuntimeOptions, scope_table is None; in that configuration claim_admission reaches HoldingsScopeGate::admits, whose unarmed early return is Announce. Consequently this new check still accepts an outsider's self/family claim into ObservedClaims, allowing it to affect rarity and repair decisions. The self/family path now resolves its audience from the directory and does not need the address table, so it must not inherit the legacy table-arming bypass.
Useful? React with 👍 / 👎.
| Err(e) => { | ||
| tracing::debug!( | ||
| content_id, | ||
| error = %e, | ||
| "swarm_runtime.converger: the cohort audience is unreadable — the tuple governs" | ||
| ); | ||
| None |
There was a problem hiding this comment.
Keep cohort content when audience lookup fails
For self/family content, a transient audience_nodes directory error returns None, which is indistinguishable from tuple-mode or non-cohort content to converger_tick; the code then runs the generic should_eject_with_diversity path and may emit an eviction for content whose actual policy requires every audience node to retain a full copy. On an unreadable audience, the safe behavior is to skip eviction/retain the copy rather than silently falling back to the tuple.
Useful? React with 👍 / 👎.
| if let Some(audience) = full_holding_audience(holdings, directory, &content_id).await { | ||
| if let Some(sink) = sink { | ||
| sink(if observed_count < audience { | ||
| SwarmEvent::RepairNeeded { |
There was a problem hiding this comment.
Compare the full-holding target with actual holders
For a below-threshold self/family audience, audience is the total number of audience nodes, but observed_count is weighted_holder_equivalents: normal inbound SignatureOnly claims contribute only half a holder, and the local node is absent because publishing skips self. Thus even when every audience device is holding and claiming the content, the count remains below the audience size and this branch continually emits RepairNeeded; use a holder count in the same units that includes the local holding, or derive completion from custody reports.
Useful? React with 👍 / 👎.
| if let Some(audience) = full_holding_audience(holdings, directory, &content_id).await { | ||
| if let Some(sink) = sink { | ||
| sink(if observed_count < audience { | ||
| SwarmEvent::RepairNeeded { |
There was a problem hiding this comment.
Preserve hard deletion for revoked cohort content
When a self/family audience is below the feasibility floor, this branch ignores the previously computed consent and unconditionally continues after emitting RepairNeeded or Keep. If an authorized withdrawal marked the content Revoked but immediate eviction was unwired or failed, the generic policy's EjectHardDelete is explicitly the retry path; this early continue prevents that retry forever and can instead request repair of withdrawn content. Handle ConsentState::Revoked before the full-holding branch.
Useful? React with 👍 / 👎.
| None => std::future::pending::<()>().await, | ||
| } | ||
| } => { | ||
| let sweep = self.durability_sweep().await; |
There was a problem hiding this comment.
Move durability sweeps off the pull receive loop
On each durability tick, the sole puller loop awaits the entire sweep before it can receive new rows, process due retries, or dispatch key-woken DAGs. A sweep can perform thousands of sequential directory/blob reads, so on a large drive or slow backend the bounded 256-entry PullSink fills and its nonblocking offer path permanently drops subsequent pulls until those rows happen to be offered again; retries and newly arrived grants are also stalled for the sweep's duration. Run the sweep concurrently or incrementally so the receiver remains serviced.
Useful? React with 👍 / 👎.
| cursor = page.resume; | ||
| if cursor.is_none() || read >= MAX_FILES { | ||
| break; |
There was a problem hiding this comment.
Resume durability scans beyond the file cap
Once read reaches MAX_FILES, the scan breaks without retaining a resume cursor, while every later sweep starts each room again from None. A room containing more than 4096 files therefore has all rows after the first 4096 permanently excluded from custody refresh and repair; with multiple rooms, once the shared counter reaches the cap, subsequent rooms repeatedly process only their first page because the outer loop continues with read already over the limit. Persist a rolling cursor or otherwise rotate the bounded scan so every file is eventually visited.
Useful? React with 👍 / 👎.
…804) * fix(swarm,blob_swarm): six durability defects from the #802 review (#763) - A self/family holding claim is decided by persist's cohort audience before the scope table's arming check: on a runtime with no table, the unarmed Announce admitted an outsider's claim into rarity and repair. With no directory, the holding is withheld (CC 5.2), never announced to an unknown peer. The v18 'no table means open' pin now covers only the tiers the table governs. - The converger's holding target is a CohortTarget { Full(n), Tuple, Unknown }: an unreadable audience keeps the content this tick, rather than falling through to the tuple's eviction. - Full holding counts each signed holder once, plus the local copy, not the #582 weighted equivalents (those guard deletes), so a fully held content is kept, not repaired forever. - A Revoked cohort content skips the full-holding branch, so EjectHardDelete stays its retry path. - The durability sweep runs as its own task (one at a time, aborted on exit), so the puller keeps receiving rows, retries and key wakes. - The bounded sweep rolls: it saves its room and page cursor and resumes there, so a room past MAX_FILES is eventually visited in full. Witnesses (each fails with its fix reverted): a_cohort_claim_is_refused_from_an_outsider_without_a_scope_table_802, a_fully_held_cohort_content_is_kept_not_repaired_802, a_revoked_cohort_content_is_not_kept_by_the_full_holding_branch_802, the_durability_pass_rolls_past_its_file_budget_802. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011ksniaWCvWoUwFQHRG3HQL * release(40.0.1): six durability fixes from the v40.0.0 review (#763) PATCH, same pins (persist v53.0.1, verify v19.0.0), no API change. Evidence TSV at ciris-edge@v40.0.1. Notes: docs/RELEASE_NOTES.md § v40.0.1. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011ksniaWCvWoUwFQHRG3HQL --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Release PR for v40.0.0 (MAJOR from v39.1.0). It is
prestage/persist-v53(bac50f3, 18 commits on maindc1ab8c, built and adopted by CIRISServer as it landed) plus the release commit (version, evidence TSV,docs/RELEASE_NOTES.md§ v40.0.0).tag = "v53.0.1"(85fa53a4, certified; v53.0.0 → v53.0.1 is the custody-binding retraction fix only),version = "53", wheel floor>=53.0.1,<54; CIRISVerifyv19.0.0.REPLICATION_POLICY_HASH1860451c…,CONSENT_GRAMMAR_HASH4d473eac…,SERVE_ADVERTISE_POLICY_HASH68c5298b….BundleGateRefusalandProvisioned#[non_exhaustive]with new variants; the serve hash; the S1 audience behaviour (server-class nodes get no self/family). The list is in the notes.Local gates on this tree: lib suite (1865 in two halves), clippy -D warnings on both feature sets, the file/withdrawal/membership integration tests, and the evidence and hash tests on the release commit.
Known, open: #771, #796, #800, #801.
🤖 Generated with Claude Code
https://claude.ai/code/session_011ksniaWCvWoUwFQHRG3HQL