Skip to content

0.5.215: adopt edge v29.5.0 — the file door, the drive read, and the self-room rule (edge-only bump) #626

Description

@emooreatx

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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    area:drive-mediaFiles, drive, notes, blobs, media policy and ingest.area:substrate-adoptionPinning and consuming persist/edge/verify releasesenhancementNew feature or requestnext:0.5.218Proposed for the next server cut (lands with the next upstream triple).size:SUnder a day of server work.state:narrowedHas a comment stating exactly what is done and what is left.

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions