Asset Organization
DraftAlbums are Capsule’s organizational backbone: container albums are the cryptographic unit every asset belongs to, while view albums are derived, key-free presentations. On top of albums, stacks group related files (RAW+JPEG pairs, bursts, live photos) so a library stays tidy, and trash stages every destructive operation behind a signed retention window so a buggy or hostile actor cannot silently destroy data. Stacks and trash are metadata-only — they never touch the underlying asset bytes.
Implemented across capsule-core::domain::stack_type (stack-type enums), capsule-core::library (default-album resolution and client-side view evaluation), the metadata sidecar layer for stack_membership (see Metadata), the signed delete-manifest envelope for retention_until, and the service layer in capsule-api-service::album/stack for server-side enforcement (planned). The retention contract — the retention_until field signed into the delete manifest — is the load-bearing piece that prevents a hostile server from accelerating purges.
Albums
Section titled “Albums”The UI calls two different things “albums,” and the design keeps them strictly separate:
- Container albums — the real cryptographic unit. Every asset belongs to exactly one.
- View albums — derived, key-free presentations computed client-side. They hold no keys and own no assets.
Container Albums
Section titled “Container Albums”A container album is Capsule’s primary organizational unit and its primary sharing and access-control boundary. An album is an MLS group: its cryptographic identity (the per-epoch AMK) and membership operations are owned by Cryptography — Keys and MLS, and its server-side storage shape (rows, blob references, protocol_version pin) lives in the Filesystem — Server Postgres schema. This section owns the interaction surface over that machinery.
- Moves are idempotent. Every asset lives in exactly one container; moving it is a signed lifecycle action naming
(asset, target album, epoch). Replaying a move finds the target state already in place and no-ops; concurrent moves resolve through the MLS commit order — see the grouping-convergence requirement. - Membership and roles. Each member holds one of the album’s three capabilities — read (AMK only), write (AMK + write-tier key), or admin (also the admin-tier key) — delivered over MLS to that member’s devices (Keys — Album Master Keys). A role change is an MLS commit and bumps the AMK epoch.
- Invitation and join. An admin invites a user by fetching and verifying their device directory and issuing an MLS
Addfor all their devices; theWelcomedelivers the AMK range set by the album’shistory_policy(MLS — History Delivery). Inviting a user on another home server also issues a federation capability; inviting a non-account recipient uses a share link. Joining is acceptance of theWelcome; leaving or removal is an MLSRemove+ epoch bump. (The MLS membership operations this surface invokes are blocked upstream — see the MLS status note.) - Album-level policy —
history_policy, theprotocol_versionpin, and the defaultretention_until— is fixed at creation and changed only through an album upgrade ceremony, never ad hoc.
Dialog copy and on-screen presentation remain a client-UX detail.
The Default Album
Section titled “The Default Album”A container album must be explicitly created, but a brand-new account has none — so an import would have nowhere to land. Capsule guarantees a default album: a de facto, nameless container that exists for every owner from first-device enrollment onward and receives any import the user does not file elsewhere.
- De facto and nameless. It is an ordinary container album in every cryptographic and lifecycle respect — its own MLS group, random per-epoch AMK,
history_policy,protocol_versionpin, retention — but carries no user-assigned name; a client typically surfaces it as the library’s primary view. - Specially identified. Its album ID is derived deterministically from the account master key (the master key derives the identifier, not any key — see Keys — Key Chain). The ID is therefore unique per user, unguessable before creation, and recomputable on any of the user’s devices and after recovery — so a device can locate the default album from the master key alone, without waiting on a synced pointer.
- Designation is a server-side owner pointer. Which container is currently the default is a non-secret
default_album_idon the owner record (Filesystem — Server), defaulting to the derived de facto album. The pointer is not security-bearing — a write still requires real album write capability (server-side invariants, invariant 6). - One or more defaults, context-driven. A client may register scope overrides —
(scope → album)mappings that re-point the default for a context (a per-source auto-import mapping; “while viewing album X, new photos default to X”). The resolution rule,resolve_default_album(context), returns the active scope’s override if set, else the owner pointer, else the derived de facto album. It always resolves to a container — a view can never be an import destination. The import planner consumes this when the user picks no album. The scope grammar is formalized below. - Stable. Re-designating the default just moves the pointer. The current default cannot be deleted while designated — the user must repoint first, or the client recreates the derived de facto album — so import always has a home.
Scope Grammar (Local Source → Album Mapping)
Section titled “Scope Grammar (Local Source → Album Mapping)”How a local folder, camera roll, or watched directory on each platform maps to a remote album is a formal contract, not per-client improvisation. A scope is the canonical identity of an import source:
Scope { platform: PlatformTag, // closed enum (shared with device cohorts) source_kind: SourceKind, // closed enum: camera_roll | screenshots | app_collection | folder | watched_dir | removable_volume locator: String, // canonical, per the platform table below}// scope_id = SHA-256( canonical-CBOR([ "capsule-import-scope/v1", platform, source_kind, locator ]) )scope_id is deterministic (domain-separated canonical CBOR — the same discipline as every derived identifier), so two devices of the same platform looking at the same source compute the same scope, and the mapping table needs no coordination protocol. Per-platform canonical locators, chosen for stability across reinstall:
| Platform | Source | Canonical locator | Notes |
|---|---|---|---|
| iOS | camera roll / screenshots / user collection | the smart-album subtype name, or the user collection’s title-independent localIdentifier | localIdentifier survives reinstall on the same device; cross-device it maps only via the override table |
| Android | MediaStore bucket | relative path (DCIM/Camera, Pictures/Screenshots) | never BUCKET_ID — it is a hash of the display name that differs across devices and OS versions |
| Desktop | folder / watched dir | library-relative or absolute canonicalized path (symlinks resolved) | |
| Any | removable volume | volume UUID + relative path | the volume UUID makes a re-mounted card the same scope regardless of mount point |
- The mapping table lives in the library-settings document (
scope_id → album_idrows) — per-owner, E2E-encrypted, synced across devices with the same CRDT semantics as other collaborative metadata (each row an LWW register keyed byscope_id). The server never learns what a scope means. - Resolution order (first match wins): explicit user pick at import time →
scope_idoverride row → per-source-kind default row (e.g. “all screenshots → Screenshots”) → the owner’sdefault_album_idpointer → the derived de facto album. Deterministic by construction; the planner records which rule fired so a surprising destination is explainable after the fact. - Unmapped sources ask once. The first import from a new scope surfaces a “where should photos from X go?” choice, whose answer is written as the scope’s override row — automated imports never silently invent destinations.
System & Smart Albums (Views)
Section titled “System & Smart Albums (Views)”View albums are organizational surfaces computed entirely client-side over the assets the user can already decrypt (the union of their container-album memberships), materialized by querying the local index. The aggregated federated album is a view in exactly this sense — its predicate is an album-group id spanning constituents on different home servers. A view is not an MLS group, holds no AMK, owns no assets, and is not a sharing or access-control boundary — sharing happens only at the container tier. Two kinds:
- System albums — built-in and implicit. The canonical one is All — every asset the user can see; because that is the union over their containers, every asset appears in it (which is exactly why the default album matters: an import always enters some container and so shows up in All). Trash is another system view, over lifecycle state.
- Smart / dynamic albums — user-defined filtered views whose membership is a predicate over sidecar fields and AI-derived attributes (Metadata, AI). Membership is computed, never stored: editing a smart album, or an asset’s attributes, never moves or re-encrypts an asset. A definition (predicate + display name) is user content — stored in the per-owner, E2E-encrypted library-settings document declared in Metadata — How Operations Travel, synced across the user’s devices with the same CRDT semantics as other collaborative metadata, so the server never learns it. (That document’s concrete schema is a design follow-up, tracked as its own slice in the repo-root
SLICES.md; this doc owns only what it must carry — smart-album definitions and the scope-override map.)
Asset Stacking
Section titled “Asset Stacking”Related files often belong together — RAW+JPEG pairs, bursts, a video and its external audio track. Rather than clutter the library with near-identical entries, Capsule groups them into one stack via best-effort auto-detection.
Stacking is metadata-only. A stack edit modifies the stack_membership field of each member asset’s sidecar — an LWW register over Option<StackMembership> (leaving a stack is a stamped None), so concurrent stack edits from different devices converge order-independently under the grouping-convergence requirement — and emits a metadata-update provenance record per affected asset. It never deletes, rewrites, or merges the underlying asset bytes — even a “best photo” choice within a burst is just the role = primary pointer in metadata, not a destructive operation. A buggy or malicious stack edit therefore cannot lose original bytes. The full atomicity rule (stage all .tmp files, rename together, discard on any rename failure) lives in Filesystem — Atomic Writes and Threat Model — Atomicity Invariants.
Stack Membership Schema
Section titled “Stack Membership Schema”The stack_membership register’s value on each member sidecar carries:
StackMembership { stack_id: UUIDv7, stack_type: StackType, // closed enum, below role: StackRole, // primary | member | proxy member_index: Option<u32>, // ordering within the stack (burst sequence, video chapter index)}stack_type is a closed enum per protocol_version — adding a new stack type requires a new (later-dated) version. Old albums never see the new type. The authoritative value set is the closed Rust enum capsule-core::domain::stack_type, one variant per type below; the taxonomy prose is descriptive, the enum is normative.
Stack Types
Section titled “Stack Types”Photography & Mobile Stacks
- RAW + JPEG Pairs: The classic “prosumer” stack. The uncompressed RAW and the processed JPEG are treated as one asset to keep the grid tidy.
- Burst Stacks: A sequence of high-speed stills (e.g., 10–30 fps). The app identifies a “Best Photo” and tucks the rest behind it.
- Live Photos: A JPEG or HEIC paired with a 1.5–3 second video clip, managed as a single interactive unit.
- Portrait/Depth Stacks: An image paired with its depth map. Enables adjusting bokeh after the shot is taken.
- Smart Selection: AI-driven grouping of visually similar images taken within seconds of each other.
Technical & Creative Stacks
- Exposure Bracketing (HDR): Multiple shots of the same scene at different exposure levels (e.g., -2, 0, +2 EV) to be merged into a single HDR image.
- Focus Stacks: A series of shots with shifting focus points. Often used in macro photography to create “infinite” depth of field.
- Pixel Shift Stacks: Found in high-end mirrorless cameras. The sensor moves slightly to capture multiple shots, stacked for ultra-high resolution and perfect color.
- Panorama (Stitched): A sequence of horizontal or vertical shots intended to be merged into a single wide-field image.
Video & Audio Stacks
- Proxy/Optimized Stacks: Pairs a heavy “Master” file (like 8K RAW) with a lightweight “Proxy” (like 1080p ProRes) for smoother editing performance.
- Chaptered Video: Action cameras (like GoPro) often split long recordings into 4GB chunks. Files like
GOPR001.mp4andGOPR002.mp4are stacked so they appear as one continuous video. - Dual-System Audio: Groups video files with high-quality external audio (WAV/AIFF) using timecode or waveform matching.
Culling
Section titled “Culling”Culling is the review pass photographers make after a shoot: keep, undecided, toss. Capsule models it as a trinary flag per asset — pick | neutral | reject — stored in the sidecar’s cull LWW register (schema); neutral is the never-flagged default and is wire-absent. The flag is orthogonal to the numeric star rating (a reject can carry three stars; tools that conflate them force lossy workflows).
- Workflow. Flag during review (single keystroke/swipe per asset), filter the view by flag, then act: batch-move rejects to trash (the only destructive step, and it is soft-per-retention like any delete), promote picks into albums or shares. Flagging itself never touches bytes and is fully reversible.
- Groups. A stack or burst has no stored flag of its own — a group’s cull state is derived from its members (all-rejected, any-pick, else mixed), so there is no second source of truth to diverge. Flagging a collapsed stack applies the flag to each member (one
metadata-updateper member, atomically staged like any stack edit). - Sync. Like every LWW field, concurrent flags converge under the grouping-convergence requirement.
The dedicated culling UX (keyboard-driven review mode, reject-sweep) is client work tracked as its own slice in the repo-root SLICES.md; the schema and semantics above are frozen now so sidecars written today survive it unchanged.
Hidden Assets
Section titled “Hidden Assets”Every asset carries a hidden flag (sidecar LWW register, wire-absent default = visible). A hidden asset is excluded from default views — timeline, search results, and system views — and appears only in the dedicated Hidden view, which sits behind the same fresh-local-auth gate as Recently Deleted (Local Gallery — SR1). Hiding is view-layer only: the asset stays in its container album, keeps syncing, and remains reachable from contexts that reference it directly (its stack, a share it was already part of).
Hiding is for “don’t surface this” (a document photo, an awkward duplicate that must stay); it is not deletion and not access control. Sidecar-style companion files (the JPEG half of a RAW+JPEG pair, a Live Photo’s video) are already suppressed from default views by their stack role — collapsed stacks show only the primary — so hidden is not needed for them; it exists for the cases stacking cannot express.
Recycling
Section titled “Recycling”When you delete an asset, it defaults to trash (i.e. soft delete). On sync, new items in trash are essentially a metadata update rather than removal. A true “delete” operation is only performed when the user explicitly empties the trash, the asset has been in the trash for its full retention period, or the user requests immediate deletion.
For consistency, deletion of assets is functionally similar to addition and modification of assets. See Cryptography — Provenance and Authorization — The Closed Action Set.
Retention Window
Section titled “Retention Window”The trash retention window is signed into the delete manifest at delete time as the retention_until field — not server-configured at purge time. It lives in the manifest’s server-visible envelope (like action and prior_provenance_hash), so the keyless purge worker reads and enforces it without any decryption key, comparing it against the server’s own trusted clock. The default is 30 days; the user can extend it per delete or per album policy. Because retention is part of the signed manifest:
- The server cannot accelerate a purge by changing a server-side config — the cryptographic floor on retention is the signed manifest’s
retention_until. A hard purge before that timestamp is rejected (the server’s purge worker readsretention_untilfrom the manifest, not from a local policy). - The server cannot delay a purge beyond an order issued by a
trash-restoreor a signed shorter-retention re-issue — the user remains in control. - A
trash-restoreaction issued beforeretention_untilrecovers the asset, appends a new provenance record, and rewinds the local lifecycle state. The original delete manifest is not removed from the provenance chain — it remains as a record of “this was deleted on date X and restored on date Y.”
This addresses the damage scenario where a hostile server unilaterally accelerates a purge to delete an asset the user expected to be recoverable, as well as the scenario where a buggy server retains data past the user’s chosen window.
Validation
Section titled “Validation”- Stack edit metadata-only (unit). Build a stack edit; assert no asset bytes are touched on disk; only sidecars and provenance records are modified.
- Stack edit atomicity (unit). Inject a rename failure mid-bundle; assert all staged
.tmpfiles are discarded and on-disk state reflects no partial stack. - Closed stack-type enum rejection (unit). Set
stack_type = "future-stack-type"; assert structural rejection at the sidecar validator. - Retention-window honor (smoke). Issue a
deletewithretention_until = now + 30d. Mock the server clock tonow + 15d; assert purge worker refuses. Move tonow + 31d; assert purge proceeds. - Trash-restore round-trip (smoke). Delete → restore → assert asset reappears in live set, provenance chain has delete + restore records, original delete record is preserved.
- Hostile-server purge defense (smoke). Mock a server that attempts purge before
retention_until; assert the purge worker (running the no-key envelope check) refuses.
The cross-module case — full lifecycle including stack creation, member edit, soft delete, restore, and final hard purge — is one bounded E2E case in Module Map.