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 3 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 3 commits into
feat/media-rawshift-still-decode-410from
feat/sidecar-unsigned-migration-412

Conversation

@justin13888

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}); 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 UUIDv5 stack ids 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

Slice 1 (typed catalog-too-new), inside the worktree:

  • cargo check -p capsule-core — pass (exit 0).
  • cargo nextest run -p capsule-core -E 'test(catalog_newer_than_this_build) | test(too_new_catalog) | test(test_open_)' — 7 passed, 0 failed.

The remaining slices, the aggregate gates (mise run check-rust, mise run test-rust, mise run check-docs-truth, mise run check-docs) and their classifications are appended here as they land; this PR is published at its first coherent commit and repaired in the open.

  • Swift CI lane (.github/workflows/ci.yml job swift, build-ios.yml TUIST_FFI=1) — unavailable locally (no Xcode on this host); it is the only compile proof for the .swift deletions.

Risks and rollout

  • Public surface (frozen crates): see the freeze-accounting decision below for every public item removed and added. 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); a library holding unsigned sidecars still opens and lists them; the migration is an explicit verb with no CLI driver yet (follow-up filed).
  • Data: the verb writes only after copying the legacy bytes to .library/quarantine/; refusals write nothing; existing signed sidecars encode byte-identically (no schema bump — the fold is an additive _unknown key).
  • Stacking: this PR targets feat/media-rawshift-still-decode-410 and merges after it.

Related Issues

Closes #412
Refs #436 (base)

Decisions taken

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.

Unresolved review notes

(none yet)

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: 8fa1713
Status: ✅  Deploy successful!
Preview URL: https://cb93a1f4.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.
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