Files
tapir/docs/data-model.md
T
mathiasandClaude Opus 4.8 0cc441d6ce docs(data-model): isolation enforcement is live (RLS), not dormant
The "Isolation invariant" section said enforcement was dormant at
Stage 0. ADR-012 turned it on: Postgres RLS ENABLE+FORCE on every
user-owned table (migration 003_rls.up.sql), keyed off the
tapir.current_user_id GUC set by the store's withUser helper, deny-by-
default on an unset GUC. The Stage-2 two-user isolation test
(internal/adapters/store/rls_test.go) is pulled forward and passing.
Kept the ADR-011 single-user history honest.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 22:15:13 +02:00

171 lines
7.6 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Tapir — Data Model
Current assumptions for the persistence model. Scoped to **Stage 0 (single user)** and
**Stage 1 (Future B, 15 trusted users)**. Future C concerns (sharding, cross-tenant
dedup) are explicitly excluded and noted at the end.
Persistence is Postgres (ADR-002), reusing the homelab's instance with a per-tenant role
when Stage 1 arrives. Secrets (OAuth tokens, BYO keys) are **not** stored in these tables —
only opaque references to them; the secret material lives in ESO/1Password (ADR-002, ADR-006).
## Design decisions baked into this model
- **Per-user isolation, not a shared global video table.** The earlier draft proposed a
global `videos`/`transcripts` table deduped across tenants. Rejected for Future B: it
reintroduces exactly the cross-domain coupling the homelab architecture review is
removing, and at 15 users the cost of occasionally re-summarizing the same video is
trivial compared to the isolation it would cost. Each user's data is self-contained.
(Revisit only if Future C makes GPU/transcription cost dominate — a new ADR, not a default.)
- **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
was also delivered to brain is recorded as sink status on the summary.
## Entities
```mermaid
erDiagram
USER ||--o{ VIDEO_CONNECTION : has
USER ||--o{ AI_CREDENTIAL : has
VIDEO_CONNECTION ||--o{ SUBSCRIPTION : exposes
SUBSCRIPTION ||--o{ VIDEO : "produces (per user)"
VIDEO ||--o| TRANSCRIPT : "has at most one"
VIDEO ||--o| SUMMARY : "has at most one"
SUMMARY ||--o{ SINK_DELIVERY : "delivered via"
USER {
uuid id PK
text display_name
timestamptz created_at
}
VIDEO_CONNECTION {
uuid id PK
uuid user_id FK
text provider "youtube | vimeo"
text provider_account
text token_secret_ref "-> SecretStore, never the token"
text status "active | revoked | error"
timestamptz connected_at
}
AI_CREDENTIAL {
uuid id PK
uuid user_id FK
text provider "anthropic | openai | gemini"
text key_secret_ref "-> SecretStore, never the key"
text status
timestamptz created_at
}
SUBSCRIPTION {
uuid id PK
uuid user_id FK
uuid connection_id FK
text channel_id
text channel_title
timestamptz websub_expires "nullable; youtube push lease"
bool active
}
VIDEO {
uuid id PK
uuid user_id FK
uuid subscription_id FK
text provider
text provider_video_id
text title
int duration_s
timestamptz published_at
text url
timestamptz seen_at
}
TRANSCRIPT {
uuid video_id PK_FK
uuid user_id FK
text source "captions | none"
text language
text content "null when source = none"
timestamptz resolved_at
}
SUMMARY {
uuid id PK
uuid user_id FK
uuid video_id FK
text summary
jsonb highlights
jsonb takeaways
text ai_provider "local | anthropic | openai | gemini"
text ai_model
bool fallback_used
timestamptz created_at
}
SINK_DELIVERY {
uuid id PK
uuid summary_id FK
text sink "store | brain"
text status "pending | delivered | error"
text detail "nullable; error message etc"
timestamptz updated_at
}
```
## Notes per entity
- **USER** — at Stage 0 there is exactly one row. At Stage 1, identity comes via Dex; this
table holds the Tapir-side profile keyed to the Dex subject.
- **VIDEO_CONNECTION** — a connected YouTube/Vimeo account. `token_secret_ref` resolves to
the OAuth refresh token via `SecretStore`. Revocation flips `status`, doesn't delete history.
- **AI_CREDENTIAL** — optional, per provider, per user (ADR-004's Fallback). Absent for users
who only use the local stack. One row per provider max.
- **SUBSCRIPTION** — a watched channel. `websub_expires` tracks the YouTube push lease so the
watcher knows when to re-subscribe; null for poll-based (Vimeo).
- **VIDEO** — one row per (user, video) — note `user_id`, reflecting the per-user-isolation
decision. The same video seen by two users is two rows. `seen_at` is when Tapir detected it.
- **TRANSCRIPT** — at most one per video. `source = none` records "checked, no usable
transcript" so the watcher doesn't reprocess (ADR-007). `content` null in that case.
- **SUMMARY** — at most one per video. `fallback_used` + `ai_provider`/`ai_model` make the
"is local good enough?" question queryable (the Stage 0 quality signal). `highlights`/
`takeaways` as jsonb to stay schema-flexible while the output format settles.
- **SINK_DELIVERY** — one row per (summary, sink) attempt. This is where "also sent to brain"
lives — no brain tables, just a delivery row with `sink = brain`. Sinks fail independently;
a failed brain delivery doesn't fail the store delivery.
## Isolation invariant (Stage 1+) — LIVE
Every user-owned table carries `user_id`, and isolation is **enforced at the DB layer**, not
only in application code. ADR-011 shipped this surface single-user (one allowlisted subject,
enforcement dormant); **ADR-012 opened Stage 1 and turned enforcement on in the same slice.**
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`. `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
the store's `withUser` helper via `set_config('tapir.current_user_id', $1, true)` — it
auto-resets on commit/rollback, so it never leaks across a pooled connection.
- `current_setting('tapir.current_user_id', true)` uses `missing_ok = true`: an **unset** GUC
yields `NULL`, the predicate matches no rows, and access **denies by default**.
- `sink_deliveries` has no `user_id`; its policy derives ownership from the parent summary via
`EXISTS (SELECT 1 FROM summaries …)`.
- `user_identities` (the Dex-subject → user_id map) is **deliberately not RLS-enabled** — it is
auth plumbing read *before* a user_id is known; putting RLS there would deadlock. It holds no
user data.
The Stage-2 isolation bar is **pulled forward, not deferred**: `internal/adapters/store/rls_test.go`
runs two users against a non-superuser, non-`BYPASSRLS` role and asserts user A reads/writes zero
of user B's rows across every table. It ships green with the multi-user features (ADR-012); no
multi-user feature merges ahead of it passing.
## Job / processing state
Execution state (queued / running / retrying) for the watch→resolve→summarize→deliver
pipeline is owned by the worker runtime, not modelled as first-class domain tables here.
`SINK_DELIVERY.status` and `TRANSCRIPT.source` capture the durable, queryable outcomes; a
thin `jobs` table may be added when a "what's processing" view is needed (mirrors the worker
queue, doesn't replace it). Deferred until there's a reason.
## Explicitly out of scope (Future C)
- Global cross-tenant video/transcript dedup (rejected above).
- Sharding / per-tenant physical databases.
- Soft-delete + full audit trail on connections/credentials (a Stage 2 hardening item; add
via ADR when Stage 2 work starts).