From 0fc25a24edc55293018643edad5e361ac90f91b4 Mon Sep 17 00:00:00 2001 From: Mathias Date: Thu, 1 Oct 2026 17:02:05 +0200 Subject: [PATCH] docs(data-model): uploaded_transcripts, and fix the stale claim that transcripts is RLS'd Adds the private uploaded_transcripts entity and the exact-set isolation rule (ADR-033). Also corrects two lists that still named `transcripts` as an RLS'd table: it has been shared non-RLS since ADR-021 / migration 015, and these lists sit next to the very boundary that matters. Co-Authored-By: Claude Sonnet 5.5 --- docs/data-model.md | 38 +++++++++++++++++++++++++++++++++++--- 1 file changed, 35 insertions(+), 3 deletions(-) diff --git a/docs/data-model.md b/docs/data-model.md index f290124..a9e49c2 100644 --- a/docs/data-model.md +++ b/docs/data-model.md @@ -18,7 +18,12 @@ only opaque references to them; the secret material lives in ESO/1Password (ADR- not LLM re-summarization but a rate-gated, reputation-risky caption fetch (ADR-010/014), which is paid per re-fetch regardless of user count — so persisting public caption content once and sharing it strictly beats the coupling it removes. Everything else each user owns is - self-contained; `rls_test.go` proves transcripts is the single exception. + self-contained; `rls_test.go` proves transcripts is the single exception for *content*. + **Uploaded-audio transcripts are the opposite of public caption content** (private, owned by + one user) and therefore live in their own RLS-scoped `uploaded_transcripts` table, never in + `transcripts` (ADR-033). The isolation test now also pins the **exact set** of tables with row + security off (`transcripts`, `user_identities`, `invitations`, `schema_migrations`), so a new + table added without RLS fails CI. - **Secrets by reference only.** Tables hold a `secret_ref` (opaque string/UUID resolved via the `SecretStore` port), never tokens or keys. - **The brain sink is just a delivery target.** No brain-specific tables. Whether a summary @@ -41,6 +46,7 @@ erDiagram VIDEO ||--o| SUMMARY : "has at most one" SUMMARY ||--o{ SINK_DELIVERY : "delivered via" USER ||--o{ CHANNEL_ERROR : "reports unavailable channels" + USER ||--o{ UPLOADED_TRANSCRIPT : "uploads (private, ADR-033)" USER { uuid id PK @@ -136,12 +142,27 @@ erDiagram timestamptz first_seen timestamptz last_seen } + UPLOADED_TRANSCRIPT { + uuid id PK + uuid user_id FK "-> USER, ON DELETE CASCADE, NOT NULL; RLS ENABLE+FORCE (migration 017)" + text title + text status "queued | transcribing | done | failed" + text content "nullable until done; the transcript text" + jsonb segments "nullable until done; [{start,end,text}] in seconds" + text endpoint "which backend served it: iguana/... | berget/... (berget = audio left the homelab)" + float audio_seconds + text error_kind "typed: interrupted | transcription_failed — never a provider string" + timestamptz created_at + timestamptz finished_at + } ``` `SUMMARY_ACTION` has `UNIQUE (user_id, video_id, action)`; `VIDEO_CONNECTION` has `UNIQUE (user_id, provider)` (one connection per provider — reconnect upserts in place). RLS (`ENABLE` + `FORCE`) is on **every solid user-owned table above** — `users`, `videos`, -`transcripts`, `summaries`, `summary_actions`, `video_connections`, `channel_errors`. +`summaries`, `summary_actions`, `video_connections`, `channel_errors`, `uploaded_transcripts`. +(`transcripts` is deliberately **not** in this list: since ADR-021 it is shared public caption +content with no `user_id`.) `sink_deliveries` is RLS'd via an `EXISTS` on its parent summary; `user_identities` is intentionally **not** RLS'd (auth plumbing). See the *Isolation invariant* section for the mechanism. @@ -197,6 +218,14 @@ mechanism. the most recent discovery pass. Upserted per scheduler pass (`last_seen` refreshed each run); surfaced on the account page as a warning. Cascades on user deletion. Primary key is `(user_id, channel_id)`. FORCE RLS'd. +- **UPLOADED_TRANSCRIPTS** (migration 017, ADR-033) — the **private** transcript of audio a user + uploaded. Deliberately a separate table from the shared `transcripts`: that one is non-RLS on the + grounds that it holds only public caption content, and a recorded meeting is the opposite. + `user_id NOT NULL ... ON DELETE CASCADE`, FORCE RLS'd. `status` runs `queued → transcribing → + done | failed`; `done` requires `content`, `failed` requires a typed `error_kind` (CHECK + constraints). `endpoint` records which backend served it — `berget/...` means the audio left the + homelab. The audio itself is never stored. `segments` keeps the timestamps (re-transcribing 90 + minutes costs ~7). Unlike `summary_actions`/`login_events` it **does** cascade on user deletion. - **LOGIN_EVENTS** (migration 010) — throttled one-row-per-(user, date) login stamp. Used by the Stage-0 gate query (VISION §Stage 0: "returned and used in ≥2 distinct weeks"). @@ -209,7 +238,10 @@ enforcement dormant); **ADR-012 opened Stage 1 and turned enforcement on in the Enforcement is **Postgres Row-Level Security** (migration `003_rls.up.sql`): - RLS is `ENABLE`d **and** `FORCE`d on every user-owned table — `users`, `videos`, - `transcripts`, `summaries`, `summary_actions`, `video_connections`, `channel_errors`. + `summaries`, `summary_actions`, `video_connections`, `channel_errors`, and (migration 017) + `uploaded_transcripts`. `transcripts` is **not** one of them: ADR-021 made it shared public + content, and migration 003's original per-user `transcripts` policy was dropped with that + table in migration 015. `FORCE` is load-bearing: the app connects as the table **owner** (`tapir` role), and owners bypass RLS unless forced. - Each policy keys off the per-request GUC `tapir.current_user_id`, set transaction-locally by