docs: add ADR-013 (delete semantics) and ADR-014 (429 handling + UX)
ADR-013 records the deliberate choice that account deletion is Tapir-side only (cascade + secret purge), leaving the shared Dex identity intact — clean re-registration, but a noted GDPR-shaped gap if Future C ever arrives. ADR-014 specifies timedtext 429 handling: Retry-After-aware backoff, a single per-egress-IP rate gate shared by the batch and click paths, and honest in-flight UX (summarizing / queued-waiting / no-transcript) so a rate-limited fetch never presents as a stuck spinner or error. Whisper stays deferred pending measurement of the sustainable rate, which this work finally makes measurable.
This commit is contained in:
@@ -290,6 +290,104 @@ explicit call, with isolation as the guardrail that keeps it safe.
|
||||
|
||||
---
|
||||
|
||||
## ADR-013 — Account deletion is Tapir-side only; the Dex identity is left intact
|
||||
|
||||
**Status:** Accepted (2026-06-03)
|
||||
|
||||
**Context.** Stage 1 (ADR-012) added account deletion. A registered user is two things: a
|
||||
`users` row (plus all their data, cascade-linked) in Tapir's Postgres, and a subject identity
|
||||
in **Dex** (the homelab OIDC provider, shared across the estate — Tapir does not own it).
|
||||
"Delete my account" could mean (a) erase all Tapir-side data and secrets, or (b) that plus
|
||||
deprovision the Dex identity. The maintainer chose (a).
|
||||
|
||||
**Decision.** Deleting a Tapir account removes **only Tapir-side state**:
|
||||
- The `users` row, cascading to all user-owned tables (`videos`, `transcripts`, `summaries`,
|
||||
`sink_deliveries`, `video_connections`, and — via an **explicit delete**, because it has no
|
||||
FK — `summary_actions`). The delete test asserts the cascade reaches every table and leaves
|
||||
other users' rows untouched.
|
||||
- All of that user's secrets in the SecretStore (the per-user YouTube refresh-token refs).
|
||||
|
||||
The **Dex identity is deliberately left intact.** Tapir does not deprovision, disable, or
|
||||
modify the shared Dex directory.
|
||||
|
||||
**Consequences.**
|
||||
- **Clean re-registration:** a deleted user who logs in again arrives as a Dex-authenticated
|
||||
subject with no `users` row, so they hit the registration gate as a "new" user — no special
|
||||
resurrection path needed. This is a feature of the choice, not an accident.
|
||||
- **Right-to-erasure is partial.** The user's *identity* still exists in Dex after deletion.
|
||||
For Future B (trusted friends) this is acceptable: Dex is the maintainer's own directory and
|
||||
the identity carries no Tapir content. **But if Tapir ever moves toward Future C (real
|
||||
external/public users), this is a GDPR-shaped gap** — a true "delete my account" there must
|
||||
also deprovision or anonymise the Dex identity, which is a new ADR and likely a Dex-admin
|
||||
integration Tapir does not currently have.
|
||||
- **Blast radius stays small:** Tapir never holds write access to the shared identity provider,
|
||||
consistent with the estate's blast-radius-minimisation posture (ADR-002, architecture review).
|
||||
|
||||
**Reversibility.** Adding Dex deprovisioning later is a superseding ADR; nothing about the
|
||||
current choice blocks it. Recorded now because "deletion is partial by design" is a deliberate
|
||||
semantic that future-Tapir (and any compliance review) must know was chosen, not overlooked.
|
||||
|
||||
---
|
||||
|
||||
## ADR-014 — Timedtext 429 handling: per-host backoff + honest in-flight UX, before any Whisper reconsideration
|
||||
|
||||
**Status:** Accepted (2026-06-03)
|
||||
|
||||
**Context.** ADR-010 acquires captions from the unauthenticated `timedtext` baseUrl. Live runs
|
||||
show that endpoint **rate-limits per source IP (HTTP 429) under volume** — many videos fetched
|
||||
in one pass from one egress IP. Stage 1 (ADR-012) made this sharper in two ways: multiple users
|
||||
now drive fetches from the *same cluster egress IP*, and the v0.4.0 "Summarize" button fires an
|
||||
**immediate, synchronous-feeling** fetch on click (HTMX polls `/v/{videoId}/status`), so a 429
|
||||
now surfaces as a *user-facing stall* rather than a background batch hiccup. A throttle
|
||||
(`TAPIR_FETCH_DELAY`) exists but is a fixed inter-fetch delay, not 429-aware, and does not
|
||||
coordinate across the concurrent click-path and the `tapir run` batch path.
|
||||
|
||||
This ADR is **not** a decision to build Whisper. ADR-007/010 keep STT deferred *pending
|
||||
measurement of the sustainable caption rate* — and that rate cannot be measured while the
|
||||
client reacts badly to the 429s it already provokes. Fix the backoff and the UX first; the
|
||||
clean data then tells you whether Whisper is warranted.
|
||||
|
||||
**Decision.**
|
||||
|
||||
1. **429-aware backoff at the fetch layer.** On a 429 from the timedtext/InnerTube fetch,
|
||||
respect `Retry-After` when present; otherwise exponential backoff with jitter. This replaces
|
||||
reliance on a fixed `TAPIR_FETCH_DELAY` alone (which stays as a floor/politeness delay).
|
||||
2. **A single per-egress-IP rate gate** shared by *both* the `tapir run` batch path and the
|
||||
web click path, so they cannot collectively exceed the sustainable rate. Concurrency into
|
||||
the timedtext endpoint is serialised/limited at this gate regardless of how many users or
|
||||
goroutines are upstream. (The 429 is per *IP*, not per user — so the gate is process-/
|
||||
cluster-egress-wide, not per-`withUser`.)
|
||||
3. **Honest in-flight UX (the product-shaping part).** The status poll distinguishes states
|
||||
the user can understand instead of a spinner that silently stalls:
|
||||
- *summarizing* — actively processing (the existing tapir spinner).
|
||||
- *queued / waiting for rate limit* — fetch deferred behind the rate gate; show a calm
|
||||
"queued, this can take a few minutes when busy" state, not a stuck spinner.
|
||||
- *no transcript* — terminal, per ADR-010's degrade-never-error (a 429 that exhausts retries
|
||||
resolves to `SourceNone`, same as any unavailable caption — it must not present as a hard
|
||||
error to the user).
|
||||
The spinner promising imminence is the wrong signal under rate-limiting; the UX must be able
|
||||
to say "waiting" truthfully.
|
||||
4. **Measurement before Whisper.** Only once (1)-(3) are in and a real sustainable
|
||||
per-IP rate is observed do we revisit whether caption coverage is good enough or whether the
|
||||
deferred Whisper fallback (ADR-007) is finally warranted. That reconsideration is a future
|
||||
ADR, gated on this data.
|
||||
|
||||
**Consequences.**
|
||||
- Caption fetching becomes well-behaved under multi-user load instead of self-inflicting 429s;
|
||||
the endpoint is treated as the shared, rate-limited resource it is.
|
||||
- The click-path UX stays honest: "waiting" reads as waiting, failure degrades to "no
|
||||
transcript", never a stuck spinner or error spew.
|
||||
- A future per-IP cooldown / second egress IP / proxy becomes an option the rate gate can sit
|
||||
in front of without UX changes.
|
||||
- **Still no Whisper** — and now there's a clean path to the *data* that decides whether it's
|
||||
ever needed (`docs/homelab-integration.md` and a future ADR own that measurement).
|
||||
|
||||
**Open (tracked, not in this ADR's scope):** the actual sustainable rate number; whether a
|
||||
dedicated egress IP / outbound proxy is worth it; CronJob-driven `tapir run` interaction with
|
||||
the rate gate (the batch path moves into k3s per the deferred CronJob item).
|
||||
|
||||
---
|
||||
|
||||
## Rejected alternatives
|
||||
|
||||
Approaches considered during the 2026-06-02 planning + grill session and **deliberately not
|
||||
|
||||
Reference in New Issue
Block a user