# Plan — Hidden system drive for user-owned objects **Status:** design captured 2026-08-21. Not implemented. Sibling to `docs/plan/derived-blobs.md`, which answers "where does *derived* content live"; this one answers "where do *user-owned binaries* live". The two share one rule — **point at a file, never at a blob** — and that rule is the reason neither needs new blob-referencing tables. ## Problem — binaries in the users row `auth.users.image TEXT` (migration `20260526000000_add_user_image.sql`) holds the avatar inline, up to 512 KiB. It is the wrong home, and the cost is already measured rather than theoretical: - **It TOASTs, and every wide read pays.** The repository comment on `get_users_by_ids` records a group fan-out that "detoasted + shipped + parsed M avatars purely to discard them", fixed by adding a narrow projection (`benches/ROUND12.md §Q1`, `ROUND13.md §Q1`). That workaround exists *because* the column is in the wrong place; the rule it leaves behind — "add a wide sibling rather than widening this one back" — is a permanent tax on every future query. - **Base64 inflation.** A ~384 KB image becomes ~512 KB of TEXT. - **No dedup.** N users sharing a default or IdP-supplied avatar cost N copies. - **None of the storage stack applies** — no `EncryptedBlobBackend`, no backend migration, no key rotation, no local cache, no consistency coverage. - **Backups and replication carry it.** Binary weight lands in the logical dump and on every replica, forever. The same pressure is coming for the UI background and a signature image, so this needs a general answer, not another column. ## The rule — point at a file, never at a blob ```sql ALTER TABLE auth.users ADD COLUMN avatar_file_id UUID REFERENCES storage.files(id) ON DELETE SET NULL, ADD COLUMN background_file_id UUID REFERENCES storage.files(id) ON DELETE SET NULL, ADD COLUMN signature_file_id UUID REFERENCES storage.files(id) ON DELETE SET NULL; ``` `storage.files` is already a `BlobReferenceSource`, already covered by every consistency edge, already GC-integrated, already copy- and version-aware. A pointer to a *file* therefore adds **zero new reference sources and zero new consistency edges** — `auth.users` holds no blob reference at all, only a pointer to a row that does. Deleting the file decrements the blob refcount through the existing file-deletion path. Point at `id`, never at a name or path: a rename must not break a profile. For a small fixed set, **columns beat a table.** A table becomes right only when the object set is open-ended, and it would cost exactly what the pointer avoids. ### Rejected alternatives | Option | Why not | |---|---| | **New table → blobs/chunks** | Needs a new `BlobReferenceSource`, a new fragment in the manifest sweep, and a new dangling check. Precisely the complexity the pointer removes. | | **Direct backend paths** (`profile/{uuid}/avatar.png`) | `backend_migration` enumerates *blobs*, so a Local→S3 cutover **silently drops every avatar**. `EncryptedBlobBackend` is hash-keyed, so writes either bypass encryption or need a parallel path. `backend_consistency` raises `unknown_backend_file` (severity `anomaly`, "non-canonical file in blob namespace") per object on every sweep. And a fixed key overwritten in place breaks the immutability everything else rests on, killing `Cache-Control: immutable`. | | **Reserved folder in the user's own drive** (`.profile/`, `.oxiprofile/`) | The user can write to it, so there are **two write paths and only one is validated** — the upload endpoint's format/size checks are bypassable over WebDAV and sync. It is in the sync tree, so "hidden" is not hidden in the protocols that matter. Existence-by-path drags back the extension problem. And any reserved name squats a namespace users own — `.profile` is a POSIX shell file, so it collides by default for anyone syncing a Linux home. | | **One drive per user** | Per-user creation at signup, backfill for existing users, per-user quota exemption, and a cascade on account deletion. All avoidable — see below. | Underneath all of it: these objects are owned by the **application on the user's behalf**, not by the user as documents. Putting them in a document tree conflates the two, and every problem above follows. ## The hidden system drive **One shared drive, not one per user.** `kind = 'system'`, alongside today's `CHECK (kind IN ('personal', 'shared'))`. Files inside are owned by their respective users via the normal `created_by` / ownership columns; the drive is only a container. Sharing one drive drops per-user creation at signup, backfill for existing users, and per-user quota exemption. Deleting a user becomes a query over `storage.files` rather than a drive cascade. Properties it needs: - **Hidden at drive enumeration.** This is the single filter point, and it is why the drive beats a folder: a folder must be filtered in directory listings, search results, recent items, trash, photo indexing and sync deltas, whereas a drive is filtered once where drives are listed. Every surface must honour it — REST, WebDAV, NextCloud, search, quota reporting. **A missed filter is the characteristic bug of this design**, so it deserves a test per surface rather than per call site. - **Trash disabled.** Otherwise every replaced avatar lands in a trash nobody can see, holding a blob reference that GC cannot reclaim while retention keeps it alive — invisible storage growth with no signal. Deletion here is immediate. - **Exempt from the user quota envelope.** Nobody should pay quota for their own avatar. - **Created at install, fail-fast at boot.** If the drive is missing, panic rather than silently disabling profile objects — a silently absent avatar surface is worse than a refusal to start. - **Visible to admins.** Ops need to see it for storage accounting even though it is hidden from users. ## Visibility — per kind, in code Reads go through a service method carrying an explicit policy, audited like any other authorization decision. Because the column set is fixed and small, the policy is a `match`, not stored data — there is nothing to misconfigure, and adding a column forces adding an arm: | Object | Who may read | Why | |---|---|---| | `avatar` | **the same rule as profile visibility** | Not "any authenticated user". `AGENTS.md` has `user_profile.rejected` return **404, never 403**, for an external caller with no relationship, specifically so existence cannot be confirmed. An avatar endpoint answering 200 for any caller is an oracle around that control. | | `background` | owner only | Nobody else has a reason to fetch it. | | `signature` | owner only, plus the document render path | A handwritten signature is forgery material. It is "public" only in the sense that it appears on documents you may already read — which argues for rendering it into those documents, not exposing it as a directly-readable object. | Note the consequence: the drive's own permission model is **not** what governs these reads. The object lives in a drive and is read through a different door. That is a deliberate choice, not an oversight — record it so nobody later "fixes" it by granting cross-user drive access. ## What must NOT live here > **If losing control of it is a security incident rather than a > cosmetic bug, it stays in the database.** The system drive is for > user-facing binaries. So E2E/Vault key material — public key bundle, passphrase-wrapped private key, recovery kit — stays in `auth.users` columns. Four reasons, the last decisive: 1. **Failure-mode asymmetry.** The characteristic bug here is a missed listing filter. For a wallpaper that is cosmetic; for key material it is disclosure. 2. **Atomicity.** Rotating a passphrase rewraps the private key *together with* the credential change. A DB column makes that one transaction; a file write plus a column update cannot be atomic. 3. **Size.** A few KB — blob storage buys nothing. 4. **`EncryptedBlobBackend` encrypts under a key the server holds.** For E2E material the whole premise is that the server *cannot* decrypt. Routing a wrapped private key through the blob layer encrypts it twice, once under a key the operator controls, adding no protection while creating the impression of it. Users who want to store genuinely private *files* already have the personal drive, with the full AuthZ engine behind it. There is no gap. ## Migrating the avatar off `auth.users.image` Volume is one row per user, so unlike the thumbnail migration this needs **no read-through phase** — a single batch job is enough. **Phase 1.** Add the pointer columns and the system drive. Write path switches to files; read path prefers `avatar_file_id` and falls back to `image` when null. **Phase 2.** `profile_image_import`, a registered `JobRegistry` job (subject-first naming, per convention). For each user with a non-null `image`: 1. Decode the data URI; skip and log if it does not parse, rather than failing the batch. 2. `store_from_stream` the decoded bytes → derived blob + manifest. 3. Insert a `storage.files` row in the system drive, owned by that user. 4. Set `avatar_file_id`. Idempotent (`WHERE avatar_file_id IS NULL`), resumable via a user-id cursor, and reports imported / skipped-unparseable / failed counts. **Phase 3.** Drop `auth.users.image` and the fallback, gated on the job reporting zero remaining. Dropping the column is what actually reclaims the TOAST weight and retires the narrow-projection rule in `get_users_by_ids`. **IdP-sourced avatars.** OIDC login already refreshes the avatar ("same IdP avatar, already verified" — `user_pg_repository.rs:1435`). That path must be converted at Phase 1, not Phase 3, or it keeps writing to a column the migration is draining. ## Object catalogue **Now:** avatar, UI background, signature image. **Strong future candidates** — these are what justify a drive rather than three columns and a corner: | Object | Why it fits | |---|---| | **Data exports** (GDPR takeout, drive-export zip) | Generated async, large, downloadable, should expire. Today there is nowhere to put them. | | **Staged imports** (Google Takeout, NextCloud export) | Multi-step ingestion needs durability beyond a temp file. | | **Share-page branding / logo** | Per-user or per-org, served on public share pages. | **Same problem, different owner — this drive does not help:** `carddav.contacts.photo_url TEXT` (contact photos, today a URL or an inlined data URI) and CalDAV `ATTACH` event attachments. They are keyed by contact and by event, not by user. But the *pointer* generalises: `contacts.photo_file_id UUID REFERENCES storage.files(id)` solves them with no new blob-referencing table either — they simply live in the address book's or calendar's own drive rather than here. Own plan. ## Operational details - **Replace must delete.** Write new file → update pointer → hard-delete the previous file. `ON DELETE SET NULL` protects the pointer when a file vanishes, but nothing deletes the old file because the pointer moved. - **Validation lives at the endpoint** and is now the only write path, which is the point of not using a user-writable location. Enforce format, dimensions and size there. - **Account deletion** deletes the user's files in the system drive explicitly; the pointer columns are on the row being deleted anyway. ## Non-goals - **Secrets of any kind.** See the discriminator above. - **Per-user system drives.** One shared drive; revisit only if per-user quota or trash semantics ever become necessary. - **Contact photos and event attachments.** Same pointer pattern, different owner, different drive — separate plan. - **A generic "user objects" API.** The column set is fixed and small on purpose. Reach for a table only when it demonstrably is not. ## Open questions - Who owns the system drive row itself, and what does `drives_consistency` expect of a drive with no human owner? - Does the signature object survive the "owner only" rule, or does document rendering need a broader read path than expected? - Should exports live here or in a short-lived namespace with its own expiry, given they are the only candidate with a natural TTL? ## References - `docs/plan/derived-blobs.md` — the sibling plan; shares the point-at-a-file rule and documents the consistency coverage matrix these objects inherit for free. - `migrations/20260526000000_add_user_image.sql` — the column being retired. - `migrations/20260802100000_drives_schema_additive.sql` — the `kind IN ('personal','shared')` constraint this extends. - `src/AGENTS.md` — the backend-abstraction rules, and the anti-enumeration pattern the avatar visibility rule follows. - Memory `project_drive_naming_and_vault_reservation` — "Vault" reserved for the future E2E kind whose key material this plan explicitly keeps out of the drive.