edge v29.5.0 is tagged (277ed33). It is an edge-only bump — persist stays v46.3.1, verify stays v16.1.0, all four ABI constants unmoved, no wheel-floor move. So the repin is one tag = line, and then the interesting part: this release is the one that makes the self-file path buildable on your side.
Design of record: FSD/CONTENT_TRANSFER.md — §6.2 (the source rule), §6.3 (the self room), §6.7–§6.9 (file shape, the drive read, local-vs-crossed), §9 (the DX contract). Release note: docs/RELEASE_NOTES.md v29.5.0.
1. The pin
ciris-edge = { git = "…/CIRISEdge", tag = "v29.5.0" } # was v29.3.1
Nothing else moves. Ladder triple: edge v29.5.0 · persist v46.3.1 · verify v16.1.0.
2. What breaks (one thing, and only in Rust)
PullOutcome gained NoOtherNode { attempts, retrying } and AuthorUnresolved { attempts, retrying }; MeaningRefusal gained GroupIdAmbiguous { scope, detail }. Neither enum is #[non_exhaustive], so an exhaustive match on either needs new arms. If you match with a _ fallback you are unaffected — but for these two I would rather you did not: see §5, where each new variant is a distinct thing to see on the ladder.
Everything else is additive.
3. What you gain
files — one door for a file at any cohort.
use ciris_edge::{files::{self, FileWrite}, self_room, scope_room::ScopeRoom};
let published = files::publish(
&*dir, &store,
ciris_edge::replication::attestation_bind::Signers { node: &node_signer, actor: None },
&FileWrite {
room: &self_room::room(&owner_fed_id), // or ScopeRoom::community(room_id) / ::family(fid)
bytes: &jpeg, media_type: "image/jpeg",
filename: Some("boat.jpg"), asserted_at: Utc::now(),
},
).await?;
It seals at the room's tier, fills persist's group slot (the community at community, the owner at self, the family at family), authors the citing row with the right cohort target field, and crosses it to the room's audience. Every refusal is typed: FileError::{TooLargeForInline, ReadableByNobody, Seal, Author, Cross, Row}.
Read side — files::in_room(&*dir, &room, limit) → Vec<FileRow>, and FileRow::open(&store, viewer) → bytes or UnopenedReason. NotFetched is the first-class "on another device" state your /v1/drive shows.
self_room — the rule, so you write IO and not policy: roster(), snapshot(), and decide() -> SelfRoomAction.
scope_room::ScopeRoom — one type answering scope(), content_group_id(), table_group_id(), cohort_target_field(), row_scope_token(), widen_to(). Use it instead of spelling a group id. cohort_addressing::{group_id_for, scope_for} still work and now delegate to it.
4. How to adopt — three pieces
(a) The self-room drive, beside ensure_room_addresses
Tick it on the same cadence as the chat-room drive, and on occurrence-announce admission:
let roster = self_room::roster(&owner_fed_id, &lens).await; // the directory's answer
match self_room::decide(&own_node_key, &roster, held.as_ref(), rival.as_ref()) {
SelfRoomAction::Create => { /* CohortGroup::create, stamp the CommitClaim */ }
SelfRoomAction::PublishKeyPackage => { /* chat::key_package_attestation → cross With::MyDevices */ }
SelfRoomAction::Add(nodes) => { /* add_member per published KeyPackage → Commit + Welcome rows */ }
SelfRoomAction::Remove(nodes) => { /* remove_member → Commit row; epoch advances */ }
SelfRoomAction::Abandon { in_favour_of } => { /* drop local state; join from the winner's Welcome */ }
SelfRoomAction::Idle => { /* seal_due on the cadence */ }
SelfRoomAction::SoleDevice => { /* nothing — ends when a second device announces */ }
SelfRoomAction::NotInRoster => { /* the owner binding is wrong; never derive */ }
}
held is HeldRoom { claim, members } from your stored group; rival is a CommitClaim seen in a Welcome/Commit row for the same identity. After any Create / Add / Remove / join:
let snap = self_room::snapshot(&group, &owner_fed_id).await?;
let scope = self_room::room(&owner_fed_id).scope();
lifecycle.install(&scope, &snap)?; // first time
lifecycle.advance(&scope, &snap, Instant::now())?; // the epoch moved
lifecycle.refresh_members(&scope, &snap)?; // same epoch, roster resolved late (#648)
lifecycle.seal_due(Instant::now()); // on the cadence
Two things that are edge's, not yours, and must not be reimplemented: who creates (decide), and what a room's ids are (ScopeRoom). Both exist because two hosts inventing them independently is how one identity ends up with two rooms and two secrets.
(b) GET /v1/drive on files::in_room
#615 §3's read. Map UnopenedReason::NotFetched → "on another device" and keep it distinct from NotGranted ("this key does not open it") — they are different states with different remedies, which is why they are different variants.
(c) The mine_on_b ladder stage — red by design
Write it now, before the drive works. §5 is the expected sequence.
5. How to validate — the reds, in order
The whole point of the rung table is that a red names its rung. Run A-writes → B-reads and expect these in this order as you build:
| stage |
what you should see |
what it means |
| before (a) |
pull DEBUG outcome=FetchFailed { reason: "…NO scope address table…" }, or on a scope-native node blob_route_refusals{blob_group_not_installed} + WARN naming installed_groups=[] |
R5: no self room. Expected until the drive lands |
| after (a), before the peer's room converges |
blob_route_refusals{blob_holder_not_in_group} |
R5: the room exists, that holder is not in this epoch. refresh_members or advance is the remedy |
| converged |
outcome=Stored { announced: false } |
R7. announced MUST be false — a self blob never emits a holder claim (CC 5.2 / persist I52). true here is a conformance break, not a nicety |
Three things that must NEVER appear for a self pull — each is a specific regression, so fail the stage on them rather than lumping them in:
NoHolders → the §6.2 source rule regressed. A self pull must never consult the claim index; there is nothing in it, by construction.
NoMeaning(GroupWithoutId) → the §6.2 projector regressed. Note this is not the same as AuthorUnresolved, which is the legitimate "the author's directory records have not converged yet" wait.
blob_pull_sources{self:claim_index} → same as the first, seen from the counter. For self pulls the counter must show self:author_nodes and no *:claim_index key at all. It is in the metrics snapshot and the PyO3 dict.
Write-side checks, cheap and worth asserting in the stage:
published.tier == CryptoTier::InvisibleEncrypted and published.crossed == true. crossed == false means the file reached nobody — it is local-tier, and persist's E5 invariant keeps local-tier rows out of every federation stream, so it is invisible to other devices and to your own /v1/drive (FSD §6.9). It parks only when the crossing awaits an actor signature.
published.excluded empty — non-empty is partial readability (those occurrences will read NotGranted).
- No
holds_bytes: row exists anywhere for a self write.
6. What this does not buy
- Bytes do not cross until (a) is built. Everything else on the self path is green; the room's driver is the last rung, and it is yours.
- Files cap at 1 MiB until the chunk-DAG door lands —
FileError::TooLargeForInline names it (CIRISPersist#821 → CIRISEdge#633, FSD §6.7).
files::in_room is a bounded walk, not a query — AttestationFilter has no cohort_scope axis and the filtered read is not the resumable one (CIRISPersist#891, FSD §6.8). Fine for a drive of hundreds; it should not be your hot path.
- Family has the machinery but no witnesses.
ScopeRoom::Family, the projector, the source rule, the send set and files::publish(room: ScopeRoom::family(..)) all work; what family still needs is a roster function (list_families_for_member_active → nodes), its own trigger (a membership row, not an occurrence announce), and witnesses exercised with a second member — membership and principal equality are different predicates, so a self witness proves nothing about family.
7. Also in this tag
Six review findings fixed, two security-relevant: decide returns Remove before Add (a late KeyPackage could otherwise hold a revoked device in the MLS tree, deriving every epoch that followed); files::in_room matches a self listing against its identity (it previously accepted every self-scoped row, which on a multi-identity node put one person's file metadata in another's drive). Plus publish refusing seals nobody can open, the drive read paging to its limit, and two retry misclassifications.
Happy to review the drive's decide call site when you have it — the ordering constraints in there are the part worth a second pair of eyes.
edge v29.5.0 is tagged (
277ed33). It is an edge-only bump — persist stays v46.3.1, verify stays v16.1.0, all four ABI constants unmoved, no wheel-floor move. So the repin is onetag =line, and then the interesting part: this release is the one that makes the self-file path buildable on your side.Design of record:
FSD/CONTENT_TRANSFER.md— §6.2 (the source rule), §6.3 (the self room), §6.7–§6.9 (file shape, the drive read, local-vs-crossed), §9 (the DX contract). Release note:docs/RELEASE_NOTES.mdv29.5.0.1. The pin
Nothing else moves. Ladder triple: edge v29.5.0 · persist v46.3.1 · verify v16.1.0.
2. What breaks (one thing, and only in Rust)
PullOutcomegainedNoOtherNode { attempts, retrying }andAuthorUnresolved { attempts, retrying };MeaningRefusalgainedGroupIdAmbiguous { scope, detail }. Neither enum is#[non_exhaustive], so an exhaustivematchon either needs new arms. If you match with a_fallback you are unaffected — but for these two I would rather you did not: see §5, where each new variant is a distinct thing to see on the ladder.Everything else is additive.
3. What you gain
files— one door for a file at any cohort.It seals at the room's tier, fills persist's group slot (the community at
community, the owner atself, the family atfamily), authors the citing row with the right cohort target field, and crosses it to the room's audience. Every refusal is typed:FileError::{TooLargeForInline, ReadableByNobody, Seal, Author, Cross, Row}.Read side —
files::in_room(&*dir, &room, limit)→Vec<FileRow>, andFileRow::open(&store, viewer)→ bytes orUnopenedReason.NotFetchedis the first-class "on another device" state your/v1/driveshows.self_room— the rule, so you write IO and not policy:roster(),snapshot(), anddecide() -> SelfRoomAction.scope_room::ScopeRoom— one type answeringscope(),content_group_id(),table_group_id(),cohort_target_field(),row_scope_token(),widen_to(). Use it instead of spelling a group id.cohort_addressing::{group_id_for, scope_for}still work and now delegate to it.4. How to adopt — three pieces
(a) The self-room drive, beside
ensure_room_addressesTick it on the same cadence as the chat-room drive, and on occurrence-announce admission:
heldisHeldRoom { claim, members }from your stored group;rivalis aCommitClaimseen in a Welcome/Commit row for the same identity. After anyCreate/Add/Remove/ join:Two things that are edge's, not yours, and must not be reimplemented: who creates (
decide), and what a room's ids are (ScopeRoom). Both exist because two hosts inventing them independently is how one identity ends up with two rooms and two secrets.(b)
GET /v1/driveonfiles::in_room#615 §3's read. MapUnopenedReason::NotFetched→ "on another device" and keep it distinct fromNotGranted("this key does not open it") — they are different states with different remedies, which is why they are different variants.(c) The
mine_on_bladder stage — red by designWrite it now, before the drive works. §5 is the expected sequence.
5. How to validate — the reds, in order
The whole point of the rung table is that a red names its rung. Run A-writes → B-reads and expect these in this order as you build:
pullDEBUGoutcome=FetchFailed { reason: "…NO scope address table…" }, or on a scope-native nodeblob_route_refusals{blob_group_not_installed}+ WARN naminginstalled_groups=[]blob_route_refusals{blob_holder_not_in_group}refresh_membersoradvanceis the remedyoutcome=Stored { announced: false }announcedMUST befalse— a self blob never emits a holder claim (CC 5.2 / persist I52).truehere is a conformance break, not a nicetyThree things that must NEVER appear for a self pull — each is a specific regression, so fail the stage on them rather than lumping them in:
NoHolders→ the §6.2 source rule regressed. A self pull must never consult the claim index; there is nothing in it, by construction.NoMeaning(GroupWithoutId)→ the §6.2 projector regressed. Note this is not the same asAuthorUnresolved, which is the legitimate "the author's directory records have not converged yet" wait.blob_pull_sources{self:claim_index}→ same as the first, seen from the counter. For self pulls the counter must showself:author_nodesand no*:claim_indexkey at all. It is in the metrics snapshot and the PyO3 dict.Write-side checks, cheap and worth asserting in the stage:
published.tier == CryptoTier::InvisibleEncryptedandpublished.crossed == true.crossed == falsemeans the file reached nobody — it is local-tier, and persist's E5 invariant keeps local-tier rows out of every federation stream, so it is invisible to other devices and to your own/v1/drive(FSD §6.9). It parks only when the crossing awaits an actor signature.published.excludedempty — non-empty is partial readability (those occurrences will readNotGranted).holds_bytes:row exists anywhere for a self write.6. What this does not buy
FileError::TooLargeForInlinenames it (CIRISPersist#821 → CIRISEdge#633, FSD §6.7).files::in_roomis a bounded walk, not a query —AttestationFilterhas nocohort_scopeaxis and the filtered read is not the resumable one (CIRISPersist#891, FSD §6.8). Fine for a drive of hundreds; it should not be your hot path.ScopeRoom::Family, the projector, the source rule, the send set andfiles::publish(room: ScopeRoom::family(..))all work; what family still needs is a roster function (list_families_for_member_active→ nodes), its own trigger (a membership row, not an occurrence announce), and witnesses exercised with a second member — membership and principal equality are different predicates, so a self witness proves nothing about family.7. Also in this tag
Six review findings fixed, two security-relevant:
decidereturnsRemovebeforeAdd(a late KeyPackage could otherwise hold a revoked device in the MLS tree, deriving every epoch that followed);files::in_roommatches a self listing against its identity (it previously accepted every self-scoped row, which on a multi-identity node put one person's file metadata in another's drive). Pluspublishrefusing seals nobody can open, the drive read paging to its limit, and two retry misclassifications.Happy to review the drive's
decidecall site when you have it — the ordering constraints in there are the part worth a second pair of eyes.