Skip to content

feat(sdk): let a writer member adopt and restore an asset first seen deleted on an album page - #1200

Merged
justin13888 merged 2 commits into
masterfrom
feat/1198-member-adopt-at-delete
Nov 4, 2026
Merged

justin13888 merged 2 commits into
masterfrom
feat/1198-member-adopt-at-delete

Conversation

@justin13888

@justin13888 justin13888 commented Nov 4, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Before this change, an album page served a delete as a record-only tombstone: no metadata blob, no blob, original_held: false. That shape is the one peers rely on (see PR 1197). A writer member's device that first saw an asset at its delete therefore had nothing to adopt the asset from, so it could not sign the trash-restore that routes/ops.rs admits. This PR lets that device find the blobs from the delete's provenance records, which it already fetches. The album page itself does not change.

  • capsule-sdk/src/sync.rs: new pure history_sources(head_record, history) -> HistorySources.
    • The live metadata blob comes from the head's own metadata_blob_hash when the head binds one. Otherwise it comes from the newest record in the history that binds one, which is the same rule Workspace::adopt_remote_history uses.
    • The original is the head's ciphertext_hash, sized with ciphertext_len(plaintext_size).
    • New HistoryError::Unbound for a chain where no record binds a metadata blob. Malformed names head or history[i].
    • The module docs and HistoryError docs say this.
  • capsule-sdk/src/sync/tests.rs: three unit tests on a real create → edit → edit → delete chain.
    • A delete names the second edit's blob, not the create's, plus its own original and exact size.
    • A binding head names its own blob without reading its history.
    • An unbound chain, a malformed history record and a malformed head are refused.
  • capsule-sdk/src/ffi.rs: exports synced_history_sources(manifest_cbor, history) -> FfiHistorySources { metadata_blob_hash, original: FfiBlobRef } as a uniffi free function, which is pure and offline. It maps errors to FfiError::Sync through the existing From<HistoryError>. The docs of fetch_metadata_blob and fetch_provenance_history now point at it for an album-page tombstone.
  • capsule-sdk/src/ffi/workspace.rs: the FfiWorkspace::open_synced_history docs describe the album-page flow. They also say that an entry left with an empty metadata_blob is refused.
  • capsule-sdk/src/ffi/tests.rs: one new end-to-end test and one error test.
    • The end-to-end test serves an album-page-shaped tombstone with an empty metadata_blob. It runs fetch_provenance_history, then synced_history_sources, then fetch_metadata_blob and fetch_blob_to_path against the mock blob route, then open_synced_history. The asset is held in trash with its edited tags, and restore_asset succeeds. The same entry with the empty metadata blob is refused and places nothing.
    • The error test feeds a malformed head to the FFI function.
  • capsule-sdk/bindings-surface.json: regenerated with mise run gen-bindings for the new record and function.
  • capsule-docs/src/content/docs/design/sync.md: states how a writer member's device restores from the records alone.
  • capsule-server/src/routes/sync.rs: doc comments only (module docs and TombstoneDisclosure). The disclosure match at the album page and the page shape are unchanged.

Client flow for a device that first sees an asset at its delete and does not hold it:

  1. Call fetch_provenance_history(manifest_cbor).
  2. Call synced_history_sources(manifest_cbor, history).
  3. Call fetch_metadata_blob(metadata_blob_hash) and fetch_blob_to_path(original.ciphertext_hash, original.size, path).
  4. Call open_synced_history({ ..entry, metadata_blob }, history, path, dest).

On the owner's own feed the answer matches the blob the entry already names.

Validation

  • cargo test -p capsule-sdk --features ffi --lib -- history_sources named_sources names_the_newest names_its_own unbound_or_malformed first_seen_at_a_delete: 6 passed. These are the 5 new tests plus the existing at-delete adoption test.
  • mise run lint-check-ffi (clippy with CLIPPY_FLAGS, --features ffi --all-targets): clean.
  • RUSTDOCFLAGS="-D warnings" cargo doc -p capsule-sdk --no-deps --document-private-items, with and without --features ffi: clean.
  • mise run gen-bindings: surface check passed; bindings-surface.json committed.
  • cargo fmt -p capsule-sdk, cargo fmt -p capsule-server --check, and the pre-commit hooks: clean. The hooks ran format-rust, format-docs, lint-md, lint-docs, convco and check-commits.
  • Deferred to CI: the workspace-wide gates (lint-check, test-rust, doc-check-rust, check-docs) and clippy on capsule-sdk with default features.

Coverage gaps

  • The FFI end-to-end test reads the album page from a second device of the same account, not from a separate member account. The SDK has no member-account fixture. What it proves is that the device needs only the page's record-only shape; who may fetch the blobs is the server's read authority. PR 1197 tests the blob route serving a trashed asset to its album members inside the window.
  • Swift and Kotlin drains do not call synced_history_sources yet, and Swift is not compiled in this environment. The Swift drain (FFISyncDriver.swift around line 415) fetches no history for an entry with an empty metadata blob, so it opens an album-page tombstone with an empty blob, which open_synced_history refuses; Android has no managed drain yet. That client wiring is outside this SDK change; see Wire synced_history_sources into the Swift and Kotlin sync drains for an asset first seen deleted on an album page #1201.

Risks and rollout

  • Additive only. There is one new FFI record and one new function, and no wire, server or album-page change. Peers on older servers see the same page as before.
  • HistoryError gains a variant. Its only matches are in capsule-sdk (the FFI From falls through to Sync).

Issue

Closes #1198

Decisions taken

Issue 1198 - Let a writer member adopt and restore an asset first seen deleted

  1. How the member's device learns the blobs. Chosen: derive them on the client from the fetched provenance history. The live metadata blob is the one named by the newest record that binds one, and the original is the delete's own ciphertext_hash and size.
    • Rejected: naming the blobs on the album page, even behind a reader check. The album page has one shape whoever reads it, and the brief says the peer-facing page must stay exactly as PR 1197 left it.
    • Rejected: a new member-only endpoint. Server surface and contract churn for facts the signed records already carry.
    • To reverse: remove history_sources and synced_history_sources, and give TombstoneDisclosure::Restore to an account reader on an album page in capsule-server/src/routes/sync.rs.
  2. The FFI shape. Chosen: a pure free function returning addresses, so the existing fetch_metadata_blob and fetch_blob_to_path do the fetching.
    • Rejected: one async session method that fetches everything. It would duplicate both fetch paths and hide the original's size and location from the app's transfer policy.
    • To reverse: replace synced_history_sources with an FfiSession method that composes the fetches.
  3. Sizing the original. Chosen: the exact STREAM ciphertext length from the head's plaintext_size (capsule_core::crypto::encryption::ciphertext_len), which is what verify.rs already does. That allows the bounded, sized fetch_blob_to_path.
    • Rejected: an unsized fetch of the original. Originals are large, and an unsized fetch buffers in memory up to a bound.
    • To reverse: change HistorySources::original_size to Option and fetch the original unsized.

…ombstone omits from the fetched history

A device that first sees an asset at its delete on an album page has only
the delete record: the page names no metadata blob and no blob, so a
writer member's device had nothing to adopt and restore from.
history_sources reads the live metadata blob off the newest record that
binds one and the original off the delete's own commitment, sized to its
exact ciphertext; the FFI exports it as synced_history_sources. The
blobs are then fetched through the window-gated blob route and opened
with open_synced_history. The album page keeps its record-only shape.
… delete's records

The album page keeps its record-only tombstone, so a member's device
names the live metadata blob and the original from the fetched history
and fetches both from the window-gated blob route.
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Nov 4, 2026 •

Copy link
Copy Markdown

Deploying capsule with  Cloudflare Pages  Cloudflare Pages

Latest commit: e62404b
Status: ✅  Deploy successful!
Preview URL: https://82217261.capsule-22k.pages.dev
Branch Preview URL: https://feat-1198-member-adopt-at-de.capsule-22k.pages.dev

View logs

@justin13888
justin13888 merged commit bd4cd78 into master Nov 4, 2026
23 checks passed
@justin13888
justin13888 deleted the feat/1198-member-adopt-at-delete branch November 4, 2026 15:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Let a writer member adopt and restore an asset first seen deleted

1 participant