Skip to content

[FEAT] Migrate unsigned sidecars through the signed write path and delete the second reader (S-D24, S-D23 owed) - #455

Open
justin13888 wants to merge 15 commits into
feat/media-rawshift-still-decode-410from
feat/sidecar-unsigned-migration-412
Open

[FEAT] Migrate unsigned sidecars through the signed write path and delete the second reader (S-D24, S-D23 owed)#455
justin13888 wants to merge 15 commits into
feat/media-rawshift-still-decode-410from
feat/sidecar-unsigned-migration-412

Conversation

@justin13888

@justin13888 justin13888 commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator

Description

Retires the unsigned pre-signed-path sidecar shape (AssetSidecar) by migrating every such sidecar into a signed SidecarV1 + manifest + provenance chain through the existing lifecycle write path, then deletes the unsigned reader so library::rebuild_index has one shape to read (S-D24). Closes S-D23's owed item: a catalog stamped by a newer build is a typed refusal at the open boundary.

Stacked on feat/media-rawshift-still-decode-410 (PR #436); the PR targets that branch.

Summary

  • S-D23 owed: LibraryError::CatalogTooNew { found, supported } from open_library via a crate-private DatabaseDriver::open_typed (the public open keeps flattening for capsule-core-ffi); Workspace::open surfaces every library-open failure as the new LifecycleError::Library(LibraryError) instead of an Io string. Catalog untouched, lock released, tested at both boundaries.
  • Shape probe: sidecar::shape::probe (crate-private) classifies a {uuid}.cbor by its two discriminating keys alone — integer key 0 (signed) vs text version (legacy) — building no model of either shape.
  • Open outcome: Workspace::open still succeeds on a library holding unsigned sidecars; a second pass records every sidecar no .provenance.cbor anchors and exposes it through Workspace::unmigrated_sidecars() (UnmigratedSidecar { path, asset_id, shape }), one warn per file naming the verb.
  • The verb: Workspace::migrate_unsigned_sidecars(&UnsignedMigrationOptions { fallback_album, trash_retain_days }) -> UnsignedMigrationReport. Per legacy record, in asset-id order: refuse without writing (MigrationSkip::{InvalidAssetId, UnknownShape, Undecodable, IdCollision, OriginalMissing, HashMismatch, QuarantineConflict, Stranded, OutsideMonthBucket, UnusualExtension}; AlbumReadOnly reports an owed delete whose album lost its write capability); copy the legacy bytes verbatim to .library/quarantine/{uuid}.cbor + {uuid}.reason.json; admit the asset as a signed create with its legacy id, in its legacy bucket, bytes signed in place, through the create commit extracted from import_asset_with (commit_signed_create(&CreateRequest), no public signature change); carry is_deleted as a signed delete; derive deterministic RFC 9562 v8 (custom) stack ids over SHA-256 for stack_hint groups of two or more and write them at create. Ends by calling rebuild_index. Idempotent; resumes an interrupted run from the quarantine copy.
  • The fold: the whole decoded legacy map rides in the signed sidecar's _unknown under legacy-unsigned-sidecar, covered by the signature.
  • Deletions: sidecar::{AssetSidecar, StackHint, read_sidecar} (+ cfg(test) write_sidecar), sidecar/{asset_sidecar.rs, stack_hint.rs}, the legacy branch, projection, stack rebuild, helpers, and tests of library/rebuild.rs; capsule-core-ffi::{AssetSidecarRecord, StackHintRecord, serialize_sidecar, deserialize_sidecar} and capsule-core-ffi/src/sidecar.rs; the Swift FFISidecarCoder, its test file, and the CatalogFFIBridge extensions over the deleted records.
  • Docs: design/import/pipeline.md status note, a design/filesystem/maintenance.md repair-table row, SLICES rows and detail blocks for S-D23 and S-D24.

SLICES counts delta (not applied — the counts paragraph is owned by another lane): status done +1, blocked −1 (S-D24 blockeddone); the "Seven rows read blocked" paragraph and the D21 --> D24 mermaid edge at SLICES.md:419-421, :436 also describe S-D24 as blocked and are left for the same owner.

Validation

All commands run inside the worktree Capsule.worktrees/Capsule-feat-sidecar-unsigned-migration-412 (base merged at 2c742d7d).

Per-slice focused proofs (each recorded at the commit it proves):

  • Slice 1 (ae01271b): cargo check -p capsule-core — pass. cargo nextest run -p capsule-core -E 'test(catalog_newer_than_this_build) | test(too_new_catalog) | test(test_open_)' — 7 passed.
  • Slices 2+3 (8fa17139, one work-group commit — decision 13): cargo check -p capsule-core — pass. cargo nextest run -p capsule-core -E 'test(migrate_unsigned) | test(sidecar::shape) | test(lifecycle::open) | test(lifecycle::import) | test(library::rebuild) | test(library::open)' — 72 passed. cargo clippy -p capsule-core -- $CLIPPY_FLAGS (the gate's flags) — pass. cargo fmt --check -p capsule-core — pass. A pre-commit adversarial read by a separate reader produced seven findings, all fixed with tests before the commit (decision 15).
  • Slice 4 (376c83e1): cargo check -p capsule-core-ffi at the slice-4 tree — pass. mise run gen-bindings — pass (capsule_core + capsule_sdk namespaces). The retired codec lived in the capsule_core_ffi namespace, which gen-bindings does not emit, so its bindings were generated directly: cargo build -p capsule-core-ffi && cargo run -q -p capsule-core-ffi --bin uniffi-bindgen -- generate --library target/debug/libcapsule_core_ffi.a --language {swift,kotlin} --out-dir target/bindings-ffi/… — pass; grep -rl over the generated Swift and Kotlin: serializeSidecar 0 files, deserializeSidecar 0, AssetSidecarRecord 0, StackHintRecord 0; control class Catalog present in both.
  • Slice 5 (HEAD~1): cargo nextest run -p capsule-core -E 'test(library::rebuild) | test(sidecar::) | test(migrate_unsigned) | test(lifecycle::open) | test(library::open)' — 86 passed. cargo clippy -p capsule-core -- $CLIPPY_FLAGS — pass. cargo fmt --check -p capsule-core — pass. Test count 804 → 796: −4 (asset_sidecar.rs), −1 (stack_hint.rs), −3 (rebuild.rs: four legacy-shape tests removed, one reporting test added), −1 (io.rs), +1 (migrate_unsigned.rs, the keyless-rebuild test).
  • Never-strip tripwire (lifecycle::migrate_unsigned::tests::the_legacy_map_is_folded_verbatim_and_covered_by_the_signature): asserts cbor::value_to_canonical_vec(sidecar.unknown["legacy-unsigned-sidecar"]) == cbor::canonicalize(legacy_bytes) (fails if the fold drops or alters any key, future_field included), that the fold contains ("future_field", "kept verbatim"), that sidecar.verify(user_ik) holds, and that removing the fold — or future_field alone from inside it — makes verify return false.

Aggregate gates at head 77386320:

  • mise run check-rust — killed by the session's 10-minute command cap (exit 143, a signal) inside build-rust, after format-check-rust, lint-check-rust, doc-check-rust, i18n-check, i18n-guard, openapi-check-kynos, architecture-check, license-check, and translate-readme-check had passed (the aggregate stops at the first failure and had reached build-rust). Per the run rules its remaining sub-tasks were re-run individually: mise run build-rust — exit 0; mise run build-check-wasm — exit 0; mise run build-ffi — exit 0; mise run lint-check-ffi — exit 0; mise run verify-examples — exit 0; mise run gen-bindings — exit 0 (re-run at this head). That is all 15 sub-tasks of check-rust green, 9 inside the aggregate and 6 individually.

  • mise run cli-surface-checkunavailable: no such task is defined on this base (mise ERROR no task cli-surface-check found); it is not part of check-rust here.

  • mise run test-rust — exit 0: cargo nextest run --workspace 1776 passed; cargo nextest run -p capsule-core --features ffi 801 passed; cargo nextest run -p capsule-sdk --features ffi 160 passed.

  • mise run check-docs-truth — exit 0. bun install --frozen-lockfile (in capsule-docs/) — exit 0. mise run check-docs — exit 0. mise run check-md — exit 0.

  • cargo nextest list -p capsule-core at head 77386320 listed 796 tests (default features; an earlier draft of this line said 788, which counted only the lib binary); the two pre-existing warnings the test build prints (library/receipts.rs:93 unused imports, import/group.rs:211 dead code) are in files this PR does not touch and are pre-existing.

  • GitHub checks at 77386320: Commit lint, Docs, Docs truth, Markdown, Rust (fmt + clippy + build), Rust (tests), Rust cross (android, windows, linux-arm64, apple) — pass. Build Capsule.apk + :core JVM smoke — fail, pre-existing: Kotlin compile errors in capsule-android/src/androidMain/kotlin/… (Unresolved reference 'di', 'DetailViewModel', 'MuseumObject', …), files this PR does not touch; the same job fails identically on the base PR [FEAT] capsule-core::media on rawshift-image: still decode, the LQIP producer, and typed unsupported formats #436 (run 33611894405) and on the other open lanes' branches. Swift (…) and Build & test Capsule.app against the Rust core — pending at the time of writing; they are the compile proof for the .swift deletions.

  • Swift CI lane (.github/workflows/ci.yml job swift; build-ios.yml with TUIST_FFI=1) — unavailable locally (no Xcode on this host). It is the only compile proof for the .swift deletions; the Rust-side proof above shows the generated surface they compiled against no longer carries the deleted names.

Review round 1 (head 803c83b6)

Commits: 7a553d3a (F1, decision 17), 7ce0c98a (F2, decision 18), 8df7ab80 (F3, decision 19), a48cac95 (F4, decision 20), e09ea51c (F11 + F6), 803c83b6 (F5 decision 21, F8).

  • Per-commit focused runs (cargo nextest run -p capsule-core -E 'test(migrate_unsigned) | …'): 55, 61, 19, 21, 22 passed respectively; cargo fmt -p capsule-core before each commit.
  • cargo nextest run -p capsule-core — 800 passed, 0 failed. cargo nextest list -p capsule-core — 800 (796 + the 4 tests added this round).
  • mise run check-docs-truth — exit 0. mise run check-md — exit 0.
  • cargo nextest run -p capsule-core-ffi — 14 passed. mise run doc-check-rust — exit 0. mise run build-ffi — exit 0. mise run gen-bindings — exit 0.
  • mise run check-rust's 15 sub-tasks, run individually (the aggregate exceeds the session's command cap): format-check-rust 0, lint-check-rust 0, doc-check-rust 0, i18n-check 0, i18n-guard 0, openapi-check-kynos 0, architecture-check 0, license-check 0, translate-readme-check 0, build-rust 0, build-check-wasm 0, build-ffi 0, lint-check-ffi 0, gen-bindings 0, verify-examples 0.
  • mise run test-rust — exit 0: cargo nextest run --workspace 1780 passed; cargo nextest run -p capsule-core --features ffi 805 passed; cargo nextest run -p capsule-sdk --features ffi 160 passed.
  • Swift CI lane — unavailable locally, as before.

Confirming round (head 6657474c)

Commits: 3dcad84f (F-new-1, decision 22), 6657474c (F-new-2 decision 23, F-new-3, and the unused doc comment on the round-1 fault hook).

  • Focused: cargo nextest run -p capsule-core -E 'test(migrate_unsigned) | test(utils::)' — 24 passed; cargo nextest run -p capsule-core -E 'test(lifecycle::)' — 106 passed; cargo fmt -p capsule-core before each commit.
  • cargo nextest run -p capsule-core — 802 passed, 0 failed. cargo nextest list -p capsule-core — 802 (800 + the two guard tests).
  • mise run doc-check-rust — exit 0.
  • cargo check -p capsule-core --target x86_64-pc-windows-msvc --features ffiunavailable: the target is not installed on this host (error[E0463]: can't find crate for core``); CI's Windows leg (Rust cross (windows)) compiles it.
  • mise run check-docs-truth — exit 0. mise run check-md — exit 0.
  • mise run test-rust — exit 0: workspace 1782 passed; capsule-core --features ffi 807 passed; capsule-sdk --features ffi 160 passed.
  • F-new-3: write_asset_files_overwrites_wrong_bytes_and_leaves_correct_bytes_alone (different-length wrong bytes rewritten; correct bytes untouched, mtime intact) and write_asset_files_trusts_a_same_length_buffer_over_the_disk (the guard's documented limit, pinned; verify rejects the wrong original). A same-length wrong file cannot be detected without the second read decision 23 removes — see that decision's Rejected line.

Decision 24 (head 7805ba5b)

Commit: 7805ba5b perf(core): write only the signed artifacts on a metadata editlifecycle/provenance.rs:200 calls write_signed_artifacts; a metadata edit reads and writes the original zero times. New test lifecycle::import::tests::metadata_edits_neither_read_nor_rewrite_the_original (original made unreadable during caption/tag/soft-delete/restore; mtime and bytes intact after).

  • cargo nextest run -p capsule-coreSummary [ 11.139s] 803 tests run: 803 passed, 0 skipped, exit 0. cargo nextest list -p capsule-core — 803.
  • mise run doc-check-rust — exit 0.

Risks and rollout

  • Public surface (frozen crates): every removed and added public item is listed in decision 16. capsule-core-ffi loses two records and two functions from its uniffi namespace; the shipping iOS composition root injects JSONSidecarCoder, so no production path calls them.
  • Behaviour: a keyless rebuild_index no longer indexes unsigned assets (it reports them, decision 12); a library holding unsigned sidecars still opens and lists them; the migration is an explicit verb with no CLI driver yet (cli: capsule library migrate drives Workspace::migrate_unsigned_sidecars #456).
  • Data: the verb writes only after copying the legacy bytes to .library/quarantine/ and fsyncing the copy (the directory fsync is a Unix primitive and a documented no-op elsewhere, decision 22); refusals write nothing (the closing rebuild included); the signed sidecar replaces the legacy one by a single-file atomic rename; no write path — create, soft_delete, reconcile_legacy_trash, or a later edit through append_lifecycle — rewrites an original whose bytes already carry the signed hash (proved by mtime through create and delete); existing signed sidecars encode byte-identically (no schema bump — the fold is an additive _unknown key).
  • Fold size: the design docs (metadata.md, validation.md) declare no upper bound for _unknown, so none is enforced here; an oversized legacy record therefore yields a correspondingly larger signed sidecar. No documented cap is violated; nothing filed.
  • Stacking: targets feat/media-rawshift-still-decode-410 (PR [FEAT] capsule-core::media on rawshift-image: still decode, the LQIP producer, and typed unsupported formats #436) and merges after it.

Related Issues

Closes #412
Refs #436 (base), #456 (CLI verb follow-up), #457 (retire the migration once no unsigned library remains)

Decisions taken

No human approved this plan: this lane ran unattended; the record below is what it decided, for a human to read afterwards.

Issue 412 - core: migrate the unsigned sidecars and delete the second reader (S-D24)
Plan:     v1 (this document)
Branch:   feat/sidecar-unsigned-migration-412
Base:     feat/media-rawshift-still-decode-410 (head of the W-MEDIA PR #436), stacked; the PR targets that branch
Cause:    -
Touches:  capsule-core/src/sidecar/{asset_sidecar.rs (delete), stack_hint.rs (delete), shape.rs (new), io.rs, mod.rs}, capsule-core/src/library/{rebuild.rs, error.rs, open.rs}, capsule-core/src/db/driver.rs, capsule-core/src/lifecycle/{migrate_unsigned.rs (new), import.rs (extraction only), open.rs, mod.rs}, capsule-core-ffi/src/{sidecar.rs (delete), lib.rs}, capsule-swift/Modules/CapsuleCatalogFFI/{Sources/FFISidecarCoder.swift (delete), Tests/FFISidecarCoderTests.swift (delete), Sources/CatalogFFIBridge.swift (:141-215)}, capsule-swift/Modules/CapsuleCatalog/Sources/CatalogSidecar.swift (:6), capsule-docs/src/content/docs/design/{import/pipeline.md (:54), filesystem/maintenance.md (repair row)}, SLICES.md (S-D23/S-D24 rows, blocks, counts)
Will not: touch capsule-cli/**, capsule-sdk/**, capsule-core/src/ffi.rs, the SidecarV1 struct or the sidecar schema doc block, import_asset_with's signature, DatabaseDriver::open's signature, the Swift ManagedStore/JSONSidecarCoder/CatalogSidecar mock lane, legacy-review/**, or any signed bytes of an already-signed asset
Lane:     serialised behind #410; forecast collisions: #413/PR #450 on capsule-core/src/lifecycle/{import.rs, mod.rs}; #433/#434/#443 on SLICES.md
Settled:  Two shapes, disjoint on the wire (sidecar_v1.rs:314-332; asset_sidecar.rs:160; rebuild.rs:769-800). Signature covers _unknown; never strip it (metadata.md:59-64,73; schema-rules.md:44). Additive optional key within v1 needs no bump (metadata.md:83). Rebuild is the repair path, never an upgrade path, and holds no keys (versioning.md:36; maintenance.md:66; rebuild.rs:43-45). Quarantine preserves bytes verbatim with a .reason.json (client.md:35-38,44; maintenance.md:69-70); an orphaned original gets a re-derived sidecar (maintenance.md:68). The media bucket is fixed at import and drift is expected (client.md:41; lifecycle/open.rs:557-576). Sealing order (metadata.md:106-116; import.rs:435-514; provenance.rs:101-177). A catalog newer than the binary is refused untouched with an error naming both versions (versioning.md:38-44; migrate.rs:299-312). Freeze rules and the four frozen crates (PR #426 body; mise.toml:138-146). UUIDv7 for new ids, v4 where creation time must not leak (AGENTS.md, Identifiers). Apps link JSONSidecarCoder, not the FFI codec (AppEnvironment.swift:215-221).

Decisions taken.

1. Migration timing: an explicit Workspace verb that ends by calling rebuild_index; never automatic at open; never inside keyless rebuild_index
   Taken:    Workspace::migrate_unsigned_sidecars(&mut self, &UnsignedMigrationOptions) -> Result<UnsignedMigrationReport>, whose last step is library::rebuild_index (the "on rebuild" half). Option set considered: (a) automatic at Workspace::open, (b) inside keyless rebuild_index, (c) explicit verb then rebuild, (d) lazily at first write. (c) taken.
   Rejected: (a) open must not author signed records unasked and must succeed with zero keys (lifecycle/open.rs:819-854 legacy_library_without_album_store_opens_empty; open.rs:482-483 "never a failed open"); a read-only recovered album (lifecycle/mod.rs:153-157) would turn every open into an error. (b) rebuild holds no key material (rebuild.rs:43-45) and cannot sign a sidecar, a manifest, or seal a blob. (d) leaves assets unverifiable indefinitely and makes the first metadata edit a hidden import.
   Reverses: wire the verb into open_inner behind an opt-in flag; the verb and report are unchanged.

2. Admission, not quarantine: an unsigned asset is admitted as a create authored by the migrating device now, attesting only what any import attests
   Taken:    The synthesized create manifest attests the content hash of the bytes on disk (checked against the legacy hash_sha256 first), this device, this album, and now; the legacy record rides inside the signed sidecar (decision 4); the sidecar's import_timestamp is the legacy import time; the manifest timestamp is now (audit-only per schema-rules.md, Timestamp Grammar). The unsigned bytes are preserved verbatim in quarantine (decision 3).
   Rejected: Quarantine-only (bytes preserved, asset stranded): every library in existence is developer-recreated (SLICES.md:4436), so the population is small and the honest fix is admission with provenance from now rather than a permanent orphan. Refuse-on-open: locks the user out of signed assets for a repairable condition (versioning.md reserves refusal for the unrepairable too-new catalog).
   Reverses: a MigrationOptions::quarantine_only flag that copies to quarantine and skips the create.

3. The legacy bytes go verbatim to .library/quarantine/{uuid}.cbor with a .reason.json; the signed revision is written in place through write_asset_files; no file moves
   Taken:    fs::copy to quarantine before any signed write; write_asset_files (import.rs:193-213) then writes the signed sidecar at the same {uuid}.cbor path, the chain, and the blob. AssetState.capture_utc is the legacy directory's month (month_dir_timestamp, open.rs:164-172) so every file stays where it is; the sidecar's capture_timestamp carries the precedence-resolved truth.
   Rejected: A {uuid}.legacy.cbor sibling: rebuild.rs:119-121 and open.rs:97-99 would parse it as a sidecar forever, and original_extension (open.rs:147-150) would return "legacy.cbor" as the original's extension. Moving originals into their true month bucket: client.md:41 fixes the bucket at import and calls drift expected; a rename mid-run widens the crash window to "original moved, sidecar not".
   Reverses: replace fs::copy with fs::rename and drop the in-place write when the layout doc changes the bucket rule.

4. The legacy record's signed home is _unknown["legacy-unsigned-sidecar"] = the whole legacy map
   Taken:    The entire decoded legacy map (projected fields included) is folded under one hyphenated key in SidecarV1.unknown (sidecar_v1.rs:198-199), so it is signed, canonically re-sorted (cbor/mod.rs:34-38, nested maps at :154), and protected by the never-strip rule. A hyphen cannot appear in a snake_case schema field, so no future v1 key can collide.
   Rejected: A new SidecarV1 field (twelve struct-literal sites, a frozen public struct, a schema-doc edit). Dropping the fields with no signed home (original_filename, import_mode, importer_version, rawshift_version, capture_tz*, tz_db_version, file_size, duration_ms, modified_timestamp, camera_make/model): the never-strip tripwire and the data-integrity principle (maintenance.md:61) both forbid it. camera_id stays None because CameraId needs a serial the legacy record lacks.
   Reverses: promote the key to a named optional field in a later schema slice; readers already carry it verbatim.

5. The public AssetSidecar type and read_sidecar are deleted; the migration owns a private, one-purpose LegacyRecord decoder; a pub(crate) shape probe replaces the fallback in rebuild
   Taken:    sidecar::{AssetSidecar, StackHint, read_sidecar} removed from the barrel (sidecar/mod.rs:8,10,19); asset_sidecar.rs and stack_hint.rs deleted; rebuild.rs reads SidecarV1 only and counts LegacyUnsigned probes with a warn naming the verb. The decoder in lifecycle/migrate_unsigned.rs reads the handful of fields decision 2 projects and keeps the map for decision 4; it is not exported.
   Rejected: Keeping AssetSidecar as pub(crate) for the migration: S-D24's done-when says deleted outright (SLICES.md:4456-4457, :4462). Deleting all legacy decoding: then the verb cannot exist in the same tree the issue lands it in.
   Reverses: a filed follow-up deletes migrate_unsigned.rs and shape::LegacyUnsigned once no unsigned library remains.
   Filed:    follow-up "core: retire the unsigned-sidecar migration and its private decoder".

6. Stack ids for legacy stack hints are UUIDv5 over (user_id, "{method}:{key}"), only for groups of two or more
   Taken:    Deterministic across partial or repeated runs, no time leak, unique per library; roles Primary→Primary, Proxy→Proxy, else Member; member_index by uuid order; written at create through SignedImportOptions.stack (import.rs:458-461).
   Rejected: Uuid::now_v7 per run: an interrupted run would split one legacy group across two ids. The legacy string id "{method}:{key}" (rebuild.rs:500): StackMembership.stack_id is a Uuid (sidecar_v1.rs:117). AGENTS.md's v7 rule governs new ids; this derives an id for an existing grouping.
   Reverses: change one function; the fold keeps the original hint for re-derivation.

7. Album resolution: the legacy album_id when held and writable, else opts.fallback_album; the verb never mints an album
   Taken:    Mirrors the CLI, which resolves the default album at open (capsule-cli/src/lib.rs:875-877); a read-only fallback is a typed AlbumReadOnly refusal.
   Rejected: ensure_album inside the verb (mints key material as a side effect of a repair).
   Reverses: add ensure_album to the verb's prologue.

8. Trash state is carried by a Delete record after the create; deleted_at survives only in the fold
   Taken:    soft_delete(&id, opts.trash_retain_days) (organize.rs:15-23) so trash state lives where the signed design puts it (rebuild.rs:236-296 chain replay).
   Rejected: Writing is_deleted into the sidecar (no such register; rebuild would ignore it).
   Reverses: none needed.

9. S-D23 owed: LibraryError::CatalogTooNew at open_library via a crate-private DatabaseDriver::open_typed; LifecycleError::Library(#[from] LibraryError) at Workspace::open
   Taken:    open_typed returns the migrator's MigrationError (migrate.rs:299-312); open_library maps CatalogTooNew to the new LibraryError variant and everything else to Db; lifecycle/open.rs:371-372 stops stringifying.
   Rejected: Parsing the flattened SqliteFailure message (migrate.rs:341-349): string-fragile. Changing DatabaseDriver::open's signature: public, frozen, consumed by capsule-core-ffi/src/catalog.rs:12.
   Reverses: drop open_typed and match on the message.

10. The FFI unsigned codec and its Swift wrapper are retired with the core type, ahead of the core deletion
    Taken:    capsule-core-ffi/src/sidecar.rs and the four re-exports (lib.rs:53) deleted; FFISidecarCoder.swift, FFISidecarCoderTests.swift, and CatalogFFIBridge.swift:141-215 deleted; CatalogSidecar.swift:6 doc corrected. Slice 4 lands before slice 5 so capsule-core-ffi never stops compiling. The shipping app injects JSONSidecarCoder (AppEnvironment.swift:220), so no production path changes; the Swift CI lane (ci.yml:448-452) validates the deletions.
    Rejected: Relocating AssetSidecar into capsule-core-ffi: keeps an unsigned writer the docs record as removed (pipeline.md:54) and violates "any FFI consumer MUST emit identical bytes" of SidecarV1 (metadata.md:95); converge by retirement, not by relocation (SLICES.md:4452-4455).
    Reverses: restore the two files from git; nothing else depends on them.

11. No CLI verb in this lane; filed as a follow-up
    Taken:    #413's PR #450 owns capsule-cli/src/{lib.rs, cli/commands.rs, cli/help.rs} and moves help text into catalogs; a Migrate arm here would conflict on all three. The verb's call sites are its tests now and the follow-up "capsule library migrate --passphrase-stdin", recorded against the freeze's call-site rule.
    Rejected: Adding the arm anyway (guaranteed merge conflict in a lane whose base is not #450's).
    Reverses: the follow-up PR.
    Filed:    follow-up "cli: capsule library migrate drives Workspace::migrate_unsigned_sidecars".

12. Keyless rebuild_index stops indexing unsigned assets and reports them instead
    Taken:    The only reader is deleted (decision 5); rebuild logs unsigned_pending with the verb's name. This is the one user-visible regression: a legacy library rebuilt keylessly shows no legacy rows until migrated.
    Rejected: Keeping a projection without the reader (impossible) or keeping the reader (decision 5).
    Reverses: none; the migration is the path forward.

Decisions taken inside the manifest during delivery.

13. Slices 2 and 3 land as one commit (a declared work group), not two
    Taken:    `feat(core): migrate unsigned sidecars through the signed write path` carries the shape probe, the open-time report, and the verb together. The report's types (`UnmigratedSidecar`, `UnmigratedShape`) and the walk that finds unanchored sidecars (`find_unanchored`) are the verb's; splitting them would have meant committing a temporary half of `lifecycle/migrate_unsigned.rs` and rewriting it in the next commit.
    Rejected: Two commits with the types moved into `lifecycle/open.rs` (the plan's placement): leaves the verb's own domain types in the wrong module for the sake of a commit boundary.
    Reverses: none needed; the diff is the same.

14. Legacy stack ids are RFC 9562 v8 (custom) UUIDs over `SHA-256(domain ‖ user_id ‖ "{method}:{key}")`, not UUIDv5
    Taken:    `uuid`'s `v5` feature (and SHA-1) is not enabled in the workspace and `Cargo.toml` is outside the manifest; `MasterKey::derive_default_album_id` (`crypto/keys/master.rs:48`) already derives a deterministic id the same way (`uuid::Builder::from_custom_bytes`), so the verb follows that precedent. Same properties decision 6 wanted: deterministic across runs and copies, unique per user, no creation time; a test pins the version nibble to 8 and that two users derive different ids.
    Rejected: Enabling `uuid/v5` (a manifest widening for one call); `Uuid::now_v7` per run (an interrupted run splits one legacy group across two ids).
    Reverses: swap the body of `legacy_stack_id` for `Uuid::new_v5` once the feature is on; the fold keeps the original hint for re-derivation.

15. Resume rules tightened after the pre-commit adversarial read
    Taken:    A chainless signed sidecar is redone from its quarantine copy only while it is still the migration's own create — `provenance_chain_hash` unset (every later write sets it, `provenance.rs`) and the legacy fold present; anything else is `MigrationSkip::Stranded`, never overwritten. A torn (`Unknown`-shape) sidecar with a quarantine twin resumes the same way. A legacy `is_deleted` whose `delete` record never landed is applied at the start of the next run, unless the chain has ever carried a `delete` (a hand restore is left alone). Duplicate ids across month buckets admit the first and refuse the rest as `IdCollision`; a record whose `uuid` field does not name its file is refused. The in-place claim is proven by the original's unchanged mtime.
    Rejected: Copying the signed sidecar aside before a resume (the resumed record is a redo of the same create; the refusal above is what protects an edited one); tmp-then-rename for the sidecar write (the per-asset bundle atomicity of maintenance.md is its own slice).
    Reverses: relax `admit`'s `SignedWithoutChain` arm.

16. Freeze accounting — every public item removed and added across the four frozen crates
    Removed (`capsule-core`): `sidecar::AssetSidecar`, `sidecar::StackHint`, `sidecar::read_sidecar` (and the `cfg(test)` `write_sidecar`). Removed (`capsule-core-ffi`): `AssetSidecarRecord`, `StackHintRecord`, `serialize_sidecar`, `deserialize_sidecar` (two records and two functions leave the `capsule_core_ffi` uniffi namespace; `CatalogError::Sidecar` stays, its contract unchanged).
    Added (`capsule-core`, all through the `lifecycle` barrel, each with a call site in the verb or `open`): `Workspace::migrate_unsigned_sidecars`, `Workspace::unmigrated_sidecars`, `UnsignedMigrationOptions`, `UnsignedMigrationReport`, `MigrationSkip`, `UnmigratedSidecar`, `UnmigratedShape`, `LEGACY_FOLD_KEY`, `LifecycleError::Library(LibraryError)`; through `library`: `LibraryError::CatalogTooNew { found: u32, supported: u32 }`. Crate-private, not public: `sidecar::shape::{SidecarShape, probe}`, `DatabaseDriver::open_typed`, `lifecycle::import::{CreateRequest, commit_signed_create, write_signed_artifacts}`; visibility widenings inside `lifecycle` (private → `pub(super)`): `lifecycle::open::{original_extension, month_dir_timestamp}`, `lifecycle::migrate_unsigned::find_unanchored`. `capsule-wasm` and `capsule-i18n`: nothing.
    Reverses: each item's removal is one `git revert` of its commit; each addition is its `pub use` line.

Decisions taken in review round 1 (findings F1–F11, questions Q1–Q5).

17. No path rewrites an original whose bytes already carry the signed hash (Q1 → a; F1)
    Taken:    `write_asset_files` streams the bytes already at the media path through SHA-256 and writes the plaintext only when they do not hash to `asset.sidecar.hash`. That covers the create over bytes signed in place, `soft_delete`, `reconcile_legacy_trash`, and every future edit through `append_lifecycle`. The create's separate `in_place` branch is gone; `in_place` survives only to keep Move-mode release from deleting what is now the asset. `a_deleted_legacy_asset_lands_in_trash` asserts the original's mtime and bytes through create and delete.
    Rejected: (b) threading `in_place` through the delete only — leaves every other edit rewriting the original; (c) weakening the claim — the crash window is real.
    Reverses: drop the hash check in `write_asset_files`.

18. The signed sidecar replaces the legacy one by a single-file atomic rename, after the quarantine copy is fsynced (Q5 → a; F2)
    Taken:    `write_signed_artifacts` stages `{uuid}.cbor.tmp` and renames it into place (maintenance doc, Atomic Writes — single-file); `quarantine_legacy` calls `File::sync_all` on the copy and its directory before the create begins. A `cfg(test)` thread-local fault (`with_sidecar_rename_fault`, the `SealerFault` pattern) fails the write between the `.tmp` and the rename; the test proves the legacy sidecar is byte-for-byte intact, the quarantine copy present, and the rerun completes. Bundle-wide atomicity (sidecar + chain + blob renamed together) stays its own slice — decision 15's rationale, now correctly scoped to the bundle rather than the single file.
    Rejected: (c) recording the destructive window as accepted.
    Reverses: write the sidecar directly again.

19. A migrated legacy GPS fix is `GpsSource::Manual` (Q2 → a; F3)
    Taken:    The coordinates were read out of the unsigned record, not out of these file bytes, and the sidecar is signed; `Manual` is the honest provenance, matching `import::enrichment::sidecar_enrichment`'s recorded reasoning for a service-held fix. A file whose own EXIF carries a fix wins at the write site and is tagged `Exif` there. The acceptance test asserts the source.
    Rejected: (c) keeping `Exif`.
    Reverses: one enum literal.

20. A legacy asset outside a `media/{YYYY}/{YYYY-MM}` bucket, or whose original's extension is not lowercase single-segment, is refused (Q3 → a; F4)
    Taken:    `admit` requires `media_dir(root, month_dir_timestamp(dir)) == dir` and `ext` ∈ `[a-z0-9]+`, else `MigrationSkip::{OutsideMonthBucket, UnusualExtension}` — nothing written, reported. Bucket pinning is now tested where it matters: a fixture under `media/2024/2024-07` asserts the asset's bucket, every derived path, and the original's mtime.
    Rejected: (b) relocating the files into a bucket — decision 3 rejected moves, and a rename mid-run widens the crash window.
    Reverses: drop the two checks; the fold keeps the record either way.

21. SLICES `S-D24` block and the PR Summary say v8 custom UUID over SHA-256, not UUIDv5 (Q4 → a; F5)
    Taken:    `SLICES.md:4476` corrected in this lane's own block; the counts paragraph stays W-FINAL's.
    Rejected: leaving the block inconsistent with decision 14.
    Reverses: none.

Manifest widening recorded (F8): two doc-comment lines outside the manifest — `capsule-swift/Modules/CapsuleCatalog/Sources/SidecarCodec.swift:7` and `capsule-swift/Modules/ManagedStore/Sources/ImportService.swift:24` — stop naming the deleted `FFISidecarCoder`; they now say `JSONSidecarCoder` is the mock lane's codec and that a core-linking build has no CBOR sidecar codec. Nothing else in those files changed.

Decision 4, amended (F10): the fields with no signed home of their own also include `width` and `height`. `SidecarEnrichment` carries no dimensions, and `dimensions` in the signed sidecar is decode/EXIF-derived only, so the legacy values survive in the fold and are not projected.

Decisions taken in the confirming round (F-new-1 … F-new-4).

22. The quarantine directory fsync is a crate-private `utils::paths::sync_dir`, `#[cfg(unix)]` with a documented no-op elsewhere (F-new-1)
    Taken:    `quarantine_legacy` opened the directory as a file and called `sync_all`, a Unix primitive that fails on Windows and would have failed the verb on every candidate there. The fsync now goes through `crate::utils::paths::sync_dir` — the same `#[cfg(unix)]` / `#[cfg(not(unix))]` shape as `capsule-server/src/blob/fs.rs`'s `sync_dir` — placed beside `tmp_path`, whose write-then-rename it partners (`utils/paths.rs:12` already documents the intent). The copy itself is flushed through a write handle, which `FlushFileBuffers` requires. Freeze ledger: `utils::paths::sync_dir` is crate-private; `utils/paths.rs` is a one-function manifest widening the review authorised.
    Rejected: leaving the certain Windows failure undocumented; a private helper inside `migrate_unsigned` (the next writer of a durable directory entry would copy it).
    Reverses: inline the two calls again.

23. `write_asset_files` decides the plaintext write from the buffer, not from a second read of the disk (F-new-2)
    Taken:    The guard is `media_path` exists (a `stat`, no read), its length equals the buffer's, and `hash_bytes(plaintext) == asset.sidecar.hash`. Every caller today passes a buffer that either was read from the media path (`append_lifecycle`) or is the file about to become it (`commit_signed_create`; for the migration that is the media path itself); `import_backup` restores into a workspace where the media path does not exist, so its buffer never meets an existing file. I/O on a metadata edit: before this PR 1 read + 1 write of the original; after round 1, 2 reads + 0 writes; now 1 read + 0 writes. The rule for callers is in the function doc: a caller whose buffer may legitimately differ from a same-length file already at the media path is not supported by this guard and must decide the overwrite itself.
              Both branches are tested directly (different-length wrong bytes rewritten; correct bytes untouched, mtime intact); the limit is pinned by its own test (a same-length wrong file is trusted, and `verify` then rejects the asset) so it cannot change silently. `write_signed_artifacts` is `pub(super)` again: the zero-read form of a metadata edit is for `append_lifecycle` (`lifecycle/provenance.rs:200`) to call it instead of re-reading the original — a one-line change outside this lane's manifest, returned as a re-plan item rather than made.
    Rejected: re-streaming the on-disk file on every metadata edit of every asset (round 1's guard); a second entry point with the disk re-read for `backup.rs` (outside the manifest, and its caller never meets an existing file); asserting that a same-length wrong file is overwritten (impossible without the second read this decision removes).
    Reverses: swap the hash-of-buffer check for the disk re-read.

Freeze ledger, amended (F-new-4): `MigrationSkip` also carries `OutsideMonthBucket { asset_id, dir }`, `UnusualExtension { asset_id, ext }`, and `AlbumReadOnly { asset_id, album_id }` (all public through the `lifecycle` barrel, each with a call site in the verb); crate-private additions: `utils::paths::sync_dir`, `lifecycle::import::with_sidecar_rename_fault` (`cfg(test)`).

24. `append_lifecycle` writes only the signed artifacts: a metadata edit reads and writes the original zero times
    Taken:    `lifecycle/provenance.rs:200` calls `write_signed_artifacts` instead of reading the original into a buffer for `write_asset_files`. A lifecycle write never changes the original, so a caption edit on a multi-gigabyte video now touches the sidecar, the chain, and the blob, and nothing else (before this PR: one full read and one full write of the original per edit). `capsule-core/src/lifecycle/provenance.rs` is a one-line manifest widening the review authorised. A new test makes the original unreadable for the duration of caption, tag, soft-delete and restore edits and asserts they succeed with the original's mtime and bytes intact.
    Rejected: leaving a full read of a multi-GB original per caption edit (decision 23's remaining cost).
    Reverses: restore the `fs::read` + `write_asset_files` pair.

Unresolved review notes

  • Observed, out of scope, not changed: append_lifecycle (lifecycle/provenance.rs) rewrites the original plaintext over itself on every metadata write via write_asset_files; the same crash window this PR avoids on the create path exists there for every edit. Belongs with the per-asset-bundle atomicity slice named in design/filesystem/maintenance.md.
  • Resolved in review round 1 (F8): the two doc comments naming FFISidecarCoder (SidecarCodec.swift:7, ImportService.swift:24) were corrected — a recorded two-line manifest widening.
  • Resolved in review round 1 (F1, decision 17): the same-bytes plaintext rewrite in append_lifecycle noted above no longer happens; write_asset_files skips an original that already carries the signed hash.
  • Resolved (decision 24): the append_lifecycle re-read of the original is gone; lifecycle/provenance.rs is a recorded one-line manifest widening.

A catalog stamped by a newer build was refused correctly by the migrator,
but `DatabaseDriver::open` flattened `MigrationError::CatalogTooNew` into a
`SqliteFailure` message, `open_library` re-wrapped that as `LibraryError::Db`,
and `Workspace::open` stringified it into `LifecycleError::Io`, so a client
could only print it. This is the owed half of S-D23.

`open_library` now goes through a crate-private `DatabaseDriver::open_typed`
and returns `LibraryError::CatalogTooNew { found, supported }`, with the
catalog untouched and the lock released; `Workspace::open` surfaces every
library-open failure as the new `LifecycleError::Library(LibraryError)`.
The public `DatabaseDriver::open`, consumed by capsule-core-ffi, keeps
flattening, so its signature and behaviour are unchanged.
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Sep 2, 2026

Copy link
Copy Markdown

Deploying capsule with  Cloudflare Pages  Cloudflare Pages

Latest commit: 7805ba5
Status: ✅  Deploy successful!
Preview URL: https://e83498a8.capsule-22k.pages.dev
Branch Preview URL: https://feat-sidecar-unsigned-migrat.capsule-22k.pages.dev

View logs

A library written before the signed path holds, beside each original, a
flat unsigned CBOR sidecar with no provenance chain, no sealed metadata
blob, and no album key. `Workspace::open` anchors on chains, so such an
asset was silently invisible: it could not be verified, exported, or
uploaded, while a keyless `rebuild_index` still indexed it (S-D24).

`Workspace::migrate_unsigned_sidecars(&UnsignedMigrationOptions)` is the
explicit verb that brings such a library forward. Per legacy record, in
asset-id order, it refuses without writing (bad name, id collision, missing
or hash-mismatched original, a conflicting quarantine twin), copies the
legacy bytes verbatim to `.library/quarantine/{uuid}.cbor` with a sibling
`.reason.json`, then admits the asset as a signed create through the one
create commit every import takes — keeping the legacy id, the media
bucket (nothing moves; the original is signed where it lies), the legacy
import time, and folding the whole legacy map into the signed sidecar's
`_unknown` under `legacy-unsigned-sidecar`, where the signature covers it.
Rating, tags, GPS and capture time land in their signed registers with
the import precedence (EXIF over the legacy record over the legacy import
time, never now); `is_deleted` becomes a signed `delete`; `stack_hint`
groups of two or more get a deterministic v8 stack id over the user id
and the group key, written at create. The run ends with `rebuild_index`.
It is idempotent and resumable: an interrupted run's chainless or torn
sidecar is redone from its quarantine copy, but only while it is still the
migration's own create; an owed `delete` is applied on the next run unless
the asset has since been restored by hand.

Supporting pieces: a crate-private `sidecar::shape::probe` tells the two
shapes apart by their two discriminating keys alone (integer key 0 vs the
text key `version`) and builds no model of either; `Workspace::open` now
records every sidecar no chain anchors and exposes it through
`unmigrated_sidecars()`, warning once per file and naming the verb, so the
library still opens; and the body of `import_asset_with` is extracted into
`commit_signed_create(&CreateRequest)` with no change to the public
signature or to an ordinary import's behaviour — the migration is the one
caller that pins an id, a bucket, an import time, and a fold.

The unsigned reader in `rebuild_index` is left in place for the commit
that deletes it; `DatabaseDriver::open`'s doc no longer links the
crate-private `open_typed` from a public item.
`capsule-core-ffi` exposed `serialize_sidecar` / `deserialize_sidecar`
over the unsigned pre-signed-path `AssetSidecar` — a writer of the shape
S-G4 retired and S-D24 migrates away, and a second encoder of sidecar bytes
beside the signed `SidecarV1` the core alone authors. The two functions,
`AssetSidecarRecord`, `StackHintRecord`, and the module behind them are
deleted, ahead of the core type's deletion so this crate never stops
compiling. `CatalogError::Sidecar` stays; its contract is unchanged.

On the Swift side, `FFISidecarCoder` (the façade over those exports), its
test file, and the `CatalogFFIBridge` extensions converting to and from
the deleted records go with it; `CatalogSidecar`'s doc no longer calls
itself a mirror of a Rust record. The shipping composition root injects
`JSONSidecarCoder`, so no production path changes.

BREAKING CHANGE: the `capsule_core_ffi` uniffi namespace loses
`serializeSidecar`, `deserializeSidecar`, `AssetSidecarRecord` and
`StackHintRecord`.
With `Workspace::migrate_unsigned_sidecars` in place there is no reason
left to read the unsigned pre-signed-path shape: a rebuild holds no keys
and could never admit such an asset, and indexing it showed an asset the
workspace cannot verify, export, or upload. `rebuild_index` now reads one
shape, the signed `SidecarV1`; a file that fails that decode is probed,
and an unsigned one is counted and reported with a `warn` naming the
migration verb rather than indexed. Its projection, stack reconstruction,
four string helpers, and the compatibility tests go with it.

`sidecar::{AssetSidecar, StackHint, read_sidecar}` and the `cfg(test)`
`write_sidecar` are deleted along with `asset_sidecar.rs` and
`stack_hint.rs`; the one remaining decoder of the legacy shape is the
migration's private `LegacyRecord`. The migration's docs now say the
reader is gone, its refusal test asserts the closing rebuild indexed
nothing for a refused file, and the keyless-rebuild test over an
un-migrated library moves in beside it.

BREAKING CHANGE: `capsule_core::sidecar::{AssetSidecar, StackHint,
read_sidecar}` are removed; a keyless `rebuild_index` no longer indexes
unsigned sidecars.
…repair row

The import pipeline's status note no longer claims the unsigned sidecar
survives as a read model; it names the migration verb, the quarantine
copy, and the fold. The maintenance repair table gets a row for an
unsigned pre-signed-path sidecar beside the malformed-sidecar one.
SLICES marks S-D23's owed typed error closed and S-D24 done, records the
admission decision in S-D24's block, and names the two follow-ups (the
CLI verb, #456; retiring the migration once no unsigned library remains,
…gned hash

`write_asset_files` wrote the plaintext unconditionally, so every
metadata-bearing write through `append_lifecycle` — `soft_delete` among
them — rewrote the original over itself, opening a crash window in which
the only copy is truncated. The migration's create avoided that with an
`in_place` branch, but its own `soft_delete` for a legacy `is_deleted`
did not, and neither did any ordinary edit.

The plaintext is now written only when the bytes already at the media
path do not hash to the sidecar's `hash`; streaming the existing file
through SHA-256 is cheaper than rewriting it. The create's special
branch goes away (the guard covers it), `in_place` survives only to keep
Move-mode release from deleting what is now the asset, and the trash
carry-over test asserts the original's mtime is unchanged through both
the create and the delete.
…ine copy first

The signed sidecar was written straight over `{uuid}.cbor` with a plain
`fs::write`. For the migration that file *is* the legacy record, so a
crash mid-write tore the only non-quarantined copy — and the quarantine
copy itself had not been fsynced, so a power loss could take both.

The sidecar is now staged to `{uuid}.cbor.tmp` and renamed into place —
the single-file atomic write the maintenance doc specifies — so the
previous sidecar survives any failure before the rename; the stale `.tmp`
is the startup scrub's. `quarantine_legacy` fsyncs the copy and its
directory before the signed write begins. A `cfg(test)` fault hook fails
the write between the `.tmp` and the rename; the test proves the legacy
sidecar is byte-for-byte intact, the quarantine copy present, and the
rerun completes. Bundle-wide atomicity (sidecar, chain, blob renamed
together) stays its own slice.
… file

The migration tagged a legacy record's coordinates `GpsSource::Exif`,
claiming they were read out of the file bytes. They were read out of the
unsigned record, and the sidecar is signed, so `Manual` is the honest
provenance — the same reasoning the Takeout enrichment records for a
service-held fix. A file whose own EXIF carries a fix still wins at the
write site and is tagged `Exif` there.
…d extension

The migration pins each asset's files where they are by deriving
`AssetState::capture_utc` from its directory's month. That only holds
when the directory is a `media/{YYYY}/{YYYY-MM}` bucket the lifecycle
maps back to, and when the original's extension is the lowercase
single segment every derived path uses; a sidecar under `media/loose/`
or an original named `.JPG` would have produced paths pointing at files
that do not exist, or silently relocated them, which decision 3 rejects.

Both cases are now refused before anything is written, each with its own
`MigrationSkip` variant (`OutsideMonthBucket`, `UnusualExtension`), and
the bucket pinning itself is finally tested where it matters: a fixture
under `media/2024/2024-07` proves the asset's bucket, every derived path,
and the original's mtime are exactly what was on disk.
…orting

`reconcile_legacy_trash` applied the `delete` record an interrupted run
owed by calling `soft_delete`, which fails with `AlbumReadOnly` when the
asset's album has since been recovered from a backup without its write
capability — and that error aborted the whole run before a single new
candidate was looked at. The owed delete is now reported per asset as
`MigrationSkip::AlbumReadOnly` and the run continues. The report's
`trashed` doc now says what it holds: this run's `is_deleted` carry-overs
plus any owed delete applied from an earlier run.
…idecarCoder mentions

S-D24's SLICES block described the legacy stack id as UUIDv5; the code
derives an RFC 9562 v8 custom UUID over SHA-256, the construction the
default album id already uses (recorded as decision 14 in PR #455). Two
Swift doc comments still named `FFISidecarCoder`, deleted with the FFI
codec; they now say `JSONSidecarCoder` is the mock lane's codec and that
a core-linking build has no CBOR sidecar codec.
…form has none

`quarantine_legacy` fsynced the quarantine directory by opening it as a
file and calling `sync_all`. That is a Unix primitive: on Windows a
directory cannot be opened that way, so the verb would have failed on
every candidate there. The fsync moves into a crate-private
`utils::paths::sync_dir`, `#[cfg(unix)]` with a documented no-op
elsewhere — the same shape as `capsule-server`'s blob store — beside the
`tmp_path` helper whose write-then-rename it partners. The copy itself is
now flushed through a write handle, which flushing needs on every
platform.
…d disk read

`write_asset_files` re-streamed the file at the media path through SHA-256
to decide whether to skip the plaintext write, although `append_lifecycle`
had just read that same file into the buffer it passes — two reads of the
original on every metadata edit of every asset.

The guard now decides from the buffer: a `stat` of the media path, a length
comparison, and `hash_bytes(plaintext)` against the sidecar's `hash`, with
no second read. A metadata edit costs one read of the original and no
write (before this PR: one read and one write). The caller rule is in the
doc: the buffer is either the bytes read from the media path or the file
about to become it; a same-length file whose bytes differ from a correct
buffer is not detected, which no caller in the tree can produce and which
`verify` would catch. Both branches are tested directly, the limit pinned.
`write_signed_artifacts` is `pub(super)` again: it is the right call for a
metadata edit, which would then read nothing at all.

The `thread_local!` fault hook's `///` becomes `//` (an unused doc comment
the test build warned about).
`append_lifecycle` read the whole original back into memory and handed it
to `write_asset_files` on every metadata edit, although a lifecycle write
never changes the original. It now calls `write_signed_artifacts` — the
sidecar, the provenance chain, and the sealed metadata blob — so a
caption edit on a multi-gigabyte video reads and writes the original zero
times (before this PR: one full read and one full write per edit).

A new test makes the original unreadable for the duration of caption,
tag, soft-delete and restore edits and asserts they succeed with the
original's mtime and bytes intact.
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.

1 participant