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 <noreply@anthropic.com>
This commit is contained in:
2026-10-01 17:02:05 +02:00
co-authored by Claude Sonnet 5.5
parent 07375f75de
commit 0fc25a24ed
+35 -3
View File
@@ -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 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 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 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 - **Secrets by reference only.** Tables hold a `secret_ref` (opaque string/UUID resolved via
the `SecretStore` port), never tokens or keys. the `SecretStore` port), never tokens or keys.
- **The brain sink is just a delivery target.** No brain-specific tables. Whether a summary - **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" VIDEO ||--o| SUMMARY : "has at most one"
SUMMARY ||--o{ SINK_DELIVERY : "delivered via" SUMMARY ||--o{ SINK_DELIVERY : "delivered via"
USER ||--o{ CHANNEL_ERROR : "reports unavailable channels" USER ||--o{ CHANNEL_ERROR : "reports unavailable channels"
USER ||--o{ UPLOADED_TRANSCRIPT : "uploads (private, ADR-033)"
USER { USER {
uuid id PK uuid id PK
@@ -136,12 +142,27 @@ erDiagram
timestamptz first_seen timestamptz first_seen
timestamptz last_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 `SUMMARY_ACTION` has `UNIQUE (user_id, video_id, action)`; `VIDEO_CONNECTION` has
`UNIQUE (user_id, provider)` (one connection per provider — reconnect upserts in place). `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`, 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 `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 intentionally **not** RLS'd (auth plumbing). See the *Isolation invariant* section for the
mechanism. mechanism.
@@ -197,6 +218,14 @@ mechanism.
the most recent discovery pass. Upserted per scheduler pass (`last_seen` refreshed each run); 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 surfaced on the account page as a warning. Cascades on user deletion. Primary key is
`(user_id, channel_id)`. FORCE RLS'd. `(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 - **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"). 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`): 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`, - 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 `FORCE` is load-bearing: the app connects as the table **owner** (`tapir` role), and owners
bypass RLS unless forced. bypass RLS unless forced.
- Each policy keys off the per-request GUC `tapir.current_user_id`, set transaction-locally by - Each policy keys off the per-request GUC `tapir.current_user_id`, set transaction-locally by