# Plan — Derived content as blobs (tier-2 refactor) **Status:** design captured 2026-08-02, revised 2026-08-16 — keying rule, CDC reuse, backend-dispatch rule, the `content_derived_blobs` / `file_attached_blobs` pair, copy/version semantics, a consistency coverage matrix with **three** hard prerequisites (one of them a `dedup_gc` predicate that would delete the entire derived tier), migration of the existing sidecar content, and a schema trim down to the columns that carry information nothing else owns. Not implemented. Follow-up to `fix/services-use-blob-abstraction` — that PR normalised the **read-side** (services consume blobs through `BlobStorageBackend` uniformly). This plan tackles the **write-side**: services that today write derived artifacts (thumbnails, transcodes) to a local sidecar directory and would benefit from writing them through the backend abstraction instead. ## Context — the three-tier storage taxonomy Today the codebase runs three implicit tiers with no explicit separation: | Tier | Purpose | Loss on reboot? | Where today | |---|---|---|---| | **1 — Temp** | Pure scratch, deletable at reboot | ✅ fine | `std::env::temp_dir()` (ad-hoc callers). Now unified under `OXICLOUD_TEMP_DIR` (`AppConfig::temp_dir`). | | **2 — Persistent spool** | Caches; expensive but rebuildable | ⚠️ possible but painful | `/.thumbnails/`, `/.transcoded/`, `/.blob-cache/`, `/.search-index/`, `/.plugin-logs/` — all mixed into tier-3 storage today. | | **3 — Persistent data** | Source of truth | ❌ never | `/.blobs/` (Local) OR S3/Azure bucket, via `BlobStorageBackend`. Already correctly configured via `OXICLOUD_STORAGE_ENTRIES`. | **Today's misclassification**: tier-2 sidecars live under `` — the same directory as tier-3 source-of-truth data. Ops resizing / moving / backing up tier-3 accidentally moves tier-2 caches with it. Loss of tier 2 is expensive (regenerate thumbnails for every photo) but not data loss; conflating them means backup policies can't distinguish "must preserve" from "can rebuild". ## The relation map (after this refactor) Solid arrows **hold a reference** (bump a `ref_count`); dashed arrows are **dependents** — they must be cleaned up when their target dies but they keep nothing alive. ```mermaid flowchart TB subgraph RES["RESOURCE LAYER · keyed by UUID"] FILES["storage.files
id UUID PK
blob_hash VARCHAR(64)
name · folder_id · mime_type"] FAB["storage.file_attached_blobs
(file_id, kind, variant) PK
blob_hash · uploaded_by
user-supplied · never shared"] FMD["storage.file_metadata (EXIF)
file_id PK
⚠ content-derived, file-keyed"] end subgraph CON["CONTENT LAYER · keyed by BLAKE3 of source bytes"] CDB["storage.content_derived_blobs
(source_hash, kind, variant) PK
blob_hash
pure f(content) · dedupes"] BET["storage.blob_extracted_text
blob_hash PK"] FACES["faces.faces
blob_hash"] end BLOB["BLOB — the content of a file
BLAKE3 of plaintext
storage.chunk_manifests
file_hash PK · chunk_hashes[]
ref_count"] CHUNK["CHUNK — physical payload
BLAKE3 of the fragment
storage.blobs
hash PK · ref_count · orphaned_at"] BACKEND[("BlobStorageBackend
Local .blobs/ · S3 · Azure
+encryption +retry +cache")] FILES -->|"FK file_id · CASCADE"| FAB FILES -->|"FK file_id · CASCADE"| FMD FILES -->|"blob_hash"| BLOB FILES -.->|"legacy pre-CDC · no manifest"| CHUNK CDB -.->|"source_hash · dependent"| BLOB CDB -->|"blob_hash"| BLOB FAB -->|"blob_hash"| BLOB BET -.->|"dependent cache"| BLOB FACES -.->|"dependent cache"| BLOB BLOB -->|"chunk_hashes[] · 1..N ordered"| CHUNK CHUNK -->|bytes| BACKEND ``` Three things to read off it: 1. **`content_derived_blobs` touches the Blob layer twice with opposite meanings** — `source_hash` is a dependent (it keeps nothing alive; the file does), `blob_hash` is a reference holder. Conflating them is how you get either a leak or a premature reap. 2. **Every new solid arrow into the Blob layer feeds `chunk_manifests.ref_count`** — the counter nothing reconciles today. See the prerequisites below. 3. **The two new tables meet the rest of the graph only at the Blob layer.** `content_derived_blobs` has no edge to `storage.files` at all: it reaches a file only by sharing that file's `blob_hash`. That is exactly what makes it dedupe across files — and exactly why it must never hold user-chosen bytes. ## Multi-instance driver Single-instance: tier-2-as-local-cache works fine. Rebuild after reboot is annoying but bounded. Multi-instance (2+ app servers behind a load balancer): - Request for thumbnail `abc123.jpg` lands on instance A → generates it → stores locally at `.thumbnails/abc123.jpg`. - Same-URL retry lands on instance B → cache miss → regenerates from source. - Every derived asset gets recomputed N times (N = instance count) at worst. Wasteful compute, wasteful storage, inconsistent latency. The long-term fix is to put derived content on tier 3 (shared) with a local read-through cache in front. Multi-instance isn't the near- term target, but the design should leave the door open. ## Design decision — derived content IS a blob The blob storage abstraction is already: - Backend-agnostic (Local / S3 / Azure) - Encrypted uniformly (`EncryptedBlobBackend` wrapper) - Consistency-checked (`blobs_consistency`) - Migratable (`backend_migration`) - Rotatable (`backend_rotate`) - Multi-instance-ready (S3/Azure natively; Local via network mount) Reusing it for derived artifacts means no second abstraction to build and maintain, and all the operational surface (audit, migration, key rotation) applies to derived content by default. ### Keying — content-address only pure functions of the content **The rule:** an artifact may be keyed by its source's content hash **iff** it is a deterministic pure function of the source bytes. Anything influenced by user choice must be keyed by the resource it was attached to, never by content. | Artifact | Function of | Content-keyable? | |---|---|---| | server thumbnail | `f(blob bytes, variant)` | ✅ any user uploading identical bytes derives identical output — nothing to poison | | transcode | `f(blob bytes, target)` | ✅ | | extracted text | `f(blob bytes)` | ✅ — `storage.blob_extracted_text` | | face vectors | `f(blob bytes)` | ✅ — `faces.faces` | | client-uploaded preview | `f(user's choice)` | ❌ **must be file-keyed** — `storage.file_attached_blobs`, see below | This isn't a new pattern: `storage.blob_extracted_text` already chose content-keying for the same reason, and the migration says so (`migrations/20260701000000_content_search_index.sql:22-28`) — "extraction is keyed by `blob_hash`, not by file: N copies of the same PDF cost ONE extraction, and rename/move/copy never re-extract." `faces.faces` is keyed on `blob_hash` too. Thumbnails are the same class of artifact, and file-keying them would make them the odd one out among three sibling features while costing: - **the dedup fast path** — `ThumbnailRefreshHook::on_file_created` returns early when `!is_new_blob`, so 100 users uploading the same photo cost one render. File-keying means either N renders or a join back through `files.blob_hash` (content-keying through the back door, slower and with more code). - **free copies and free versions** — `on_file_copied` is a no-op today precisely because the key is content, and future versioning inherits the same property. See the copy/version axes below. For the derived side the hash is over the **produced** bytes, so: - Two files with identical thumbnails (same variant of the same source → identical bytes → identical hash) share the physical blob. Dedup wins for free. - Two variants of one source (256px vs 512px) produce different blobs. Also correct. The variant spec lives in the referring DB row, not in the storage key. Storage stays one keyspace; ownership stays per-service. ### Corollary — point at a file, never at a blob Both tables in this plan exist because their content is *not* a file: a thumbnail has no name, no folder and no place in a user's tree. When a binary **can** be a file, make it one and point at it with a `*_file_id` FK — `storage.files` is already a `BlobReferenceSource`, already covered by every consistency edge, already GC-integrated, so a file pointer costs **zero** new reference sources and zero new consistency checks. That is the rule that stops the next person adding a fourth blob-referencing table. It is what `docs/plan/hidden-system.md` applies to user avatars, backgrounds and signatures, and it extends to owners that are not users at all — `carddav.contacts.photo_file_id` would retire the inlined `photo_url TEXT` on the same terms. ### Schema ```sql CREATE TABLE storage.content_derived_blobs ( source_hash VARCHAR(64) NOT NULL, -- source Blob (no FK — see below) kind TEXT NOT NULL, -- 'thumbnail' | 'transcode' variant TEXT NOT NULL, -- 'icon' | 'preview' | 'large' | '720p' blob_hash VARCHAR(64) NOT NULL, -- the DERIVED Blob content_type TEXT NOT NULL, -- served directly; no byte-sniffing created_at TIMESTAMPTZ NOT NULL DEFAULT now(), PRIMARY KEY (source_hash, kind, variant) ); CREATE INDEX ON storage.content_derived_blobs(blob_hash); ``` **`variant` is opaque text. New axes go inside it, never into new columns.** This is the rule that keeps the table from growing, and it disposes of three columns earlier drafts proposed: - **No `format` column.** WebP vs JPEG looks like a second axis, but only the canonical rendering is persisted (below), so there is one row per variant. If a format migration ever happens — AVIF is the plausible one — it is `variant = 'preview-avif'` beside `'preview'`. Data change, not a PK migration. - **No `codec` column.** Transcoding here is a *playability fallback*, not bandwidth optimisation: one widely-compatible rendition (H.264/AAC in MP4), no negotiation, nothing to distinguish. `