Files
tapir/DECISIONS.md
T
mathiasandClaude Opus 4.8 e9b5a3f3e7
CI / Lint / Test / Vet (push) Successful in 11s
CI / Build & Import (push) Successful in 10s
feat(summarizer): resilient endpoint chain with local→cloud fallback (ADR-022)
The first friendly-pilot live run produced zero summaries: koala/phi4-mini hit
three silent failure modes — 8k context overflow on long transcripts (HTTP 400),
intermittent malformed JSON (highlights as a bare string), and no fallback wired
at all (summarizer.New(primary, nil)).

Keep phi4-mini as the fast primary and add resilience around it:

- Ordered endpoint chain (summarizer.NewChain): phi4-mini → koala/phi4-14b
  (local) → berget/mistral-small (worst-case external). All reached through the
  one LiteLLM gateway by alias.
- A parse failure now advances the chain like a transport error — the old
  Primary→Fallback shape returned the parse error without trying anyone else.
- Tolerant parse: highlights/takeaways coerce string→[]string, absorbing the
  common small-model quirk without spending a fallback round-trip.
- Transcript truncation (TAPIR_MAX_TRANSCRIPT_CHARS=18000) prevents the overflow
  rather than recovering from it; validated to fit phi4-mini's 8k window.
- Bounded completion budget (TAPIR_SUMMARY_MAX_TOKENS=1500) — the old 8192 budget
  itself contributed to the overflow.

Local-first guarantee preserved by ordering: external endpoint is tried only
after every local one fails. TAPIR_CLOUD_FALLBACK_MODEL="" disables it entirely
for client/NDA deployments.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 08:36:16 +02:00

57 KiB
Raw Permalink Blame History

Architecture Decision Records — Tapir

Lightweight ADRs. Each records a decision, its context, and its consequences at the time it was made. Decisions are append-only: to reverse one, add a new ADR that supersedes it (note the supersession in both). This is the reversibility record the VISION guardrails refer to.

Status values: Accepted · Superseded by ADR-NNN · Proposed.


ADR-001 — Go, not Python

Status: Accepted (2026-06-02)

Context. The first design draft assumed a Python/FastAPI/arq stack. The homelab is a Go estate (template-go-web, template-go-agent, mcp-chassis, hyperguild/ingestion). A Python service would be the only one of its kind, outside every shared convention, chassis, and CI pattern.

Decision. Tapir is written in Go. Python is used only where it makes no sense not to (e.g. if an ML component later genuinely requires it — none does today).

Consequences. Reuses homelab Go conventions and the mcp-chassis auth pattern if/when Tapir exposes an MCP surface. Closes the door on the Python async ecosystem (acceptable — Go's concurrency model fits the watcher/worker shape).


ADR-002 — No Supabase; reuse existing Dex / ESO / Postgres conventions

Status: Accepted (2026-06-02). The "Tapir does not write to the shared identity provider" implication is partially superseded by ADR-017 (invite flow writes Dex Password CRs); the no-Supabase / reuse-existing-primitives decision stands.

Context. The draft proposed self-hosted Supabase for auth + RLS + secrets (Vault). The homelab already runs Dex (OIDC), ESO + 1Password (secrets), and a postgres18 instance that the May-2026 architecture review is actively de-coupling by blast radius. Supabase would bundle a second auth system, a second secrets store, and its own Postgres — three duplications of things just consolidated.

Decision. No Supabase. Auth, secrets, and persistence reuse the homelab's existing primitives: Dex for identity (when external identity is needed), ESO + 1Password for secret custody, Postgres with per-tenant role/grant and the tenant= namespace label scheme for isolation when Future B/C arrives.

Consequences. No new infra to operate or back up. Multi-tenancy uses the architecture review's SC7/P6 primitives rather than Supabase RLS. For Stage 0 (single user) most of this is dormant; it activates at Stage 1.


ADR-003 — Standalone-first; brain is one sink behind an interface

Status: Accepted (2026-06-02)

Context. Tapir was initially framed as both a standalone service and a brain-ingestion pipeline. The maintainer clarified that standalone is the more important of the two.

Decision. Tapir is a standalone service. Delivery of summaries is via a Sink interface with multiple implementations. The user's own store is the primary sink; the brain is one optional sink among others. The engine does not know or care which sinks are attached.

Consequences. Brain ingestion can never dictate the core architecture (a named drift signal in VISION). Homelab mode is "the brain sink is enabled"; it is not a separate build.


ADR-004 — Copy the llm package from hyperguild/ingestion; do not lift to a shared lib

Status: Accepted (2026-06-02)

Context. Spike S5 (infra/docs/superpowers/handoffs/2026-06-02-video-adapter-placement.md) examined what Tapir could reuse from hyperguild/ingestion. The internal/llm package (Client + Router) is stdlib-only and its Router implements exactly the Primary→Fallback pattern Tapir needs for local-first → BYO-AI. It is ~3.5 KB and stable.

Decision. Copy the llm package into Tapir and own it. Do not lift it into a shared module. Lifting would couple Tapir's release cycle to the monolith to save ~100 lines — a bad trade against the "standalone owes nothing" bias.

Consequences. Minor duplication. Tapir owes nothing to hyperguild/ingestion at the dependency level. Primary = local stack via LiteLLM/piguard alias; Fallback = user BYO key.


ADR-005 — Brain sink is an HTTP brain-mcp client, not the filesystem brain package

Status: Accepted (2026-06-02)

Context. hyperguild/ingestion's internal/brain package manipulates the brain git checkout on the local filesystem (os.WriteFile into brain/wiki/...). That assumes co-location with the brain repo — fine for the monolith, wrong for a standalone service where brain is one network-reached sink.

Decision. Tapir's brain sink is a thin HTTP adapter calling brain-mcp's brain_ingest tool. It does not import or replicate the filesystem brain package.

Consequences. The brain sink works regardless of where Tapir runs. Adds a dependency on brain-mcp availability when that sink is enabled (acceptable; sinks fail independently).


ADR-006 — Outbound OAuth (YouTube/Vimeo) written fresh

Status: Accepted (2026-06-02)

Context. hyperguild/ingestion's internal/oauth package is the MCP server's inbound auth (client_credentials, authenticating claude.ai). Tapir needs outbound authorization-code OAuth to YouTube/Vimeo with per-user token custody — a different concern that happens to share a name.

Decision. Write the YouTube/Vimeo OAuth client fresh using golang.org/x/oauth2. Store per-user refresh tokens via the homelab's ESO/1Password convention (ADR-002), not a new secrets system.

Consequences. No reuse from the monolith here. Token custody is the highest-value secret surface (see VISION Stage 2); it gets the existing, vetted secret path.


ADR-007 — Captions-first; audio-download + speech-to-text deferred

Status: Accepted (2026-06-02). Acquisition mechanism superseded by ADR-010 (Data API captions.download → player/timedtext baseUrl); captions-first stance and STT deferral stand.

Context. YouTube's Data API does not expose transcripts. Options: official captions (clean, limited coverage) vs. audio download + local Whisper (broad coverage, ToS-grey, GPU-contending with the JEPA PoC on koala, breakage-prone via yt-dlp).

Decision. The core path is captions-only. When a video has no usable transcript, Tapir records "no transcript" and moves on. Audio-download + speech-to-text is a deferred, clearly-bounded optional component, not part of the core path or Stage 0.

Consequences. Some videos won't be summarized at Stage 0 — accepted. The "is this useful to me?" hypothesis is testable on captioned videos alone. Avoids GPU contention and ToS risk in the validated path.


ADR-008 — Future C (public multi-tenant SaaS) deferred behind the Stage 0 gate

Status: Accepted (2026-06-02)

Context. "Real users soon" was initially asserted, which would load Google OAuth verification, billing, and hardened multi-tenant custody early. Lowered to Future B (15 trusted users), with the maintainer as the real first customer.

Decision. Build for Stage 0 then Stage 1 (Future B). Public SaaS machinery (sign-up, billing, Google OAuth app verification at scale) is not built until the Stage 0 self-use gate passes and real external demand appears.

Consequences. Smaller, faster first build. The staged Definition of Success in VISION governs advancement. Reversible: if demand appears, a new ADR opens the Future C scope.


ADR-009 — Trunk-Based Development

Status: Accepted (2026-06-02)

Context. Platform-wide convention (homelab architecture review invariant; gitea-mcp #27): commit directly to main, one logical change per commit, every commit deployable.

Decision. Tapir follows TBD. Commit directly to main. No feature branches or PRs for solo/agent work; short-lived agent/<desc> branches only when parallel agents are active on the repo simultaneously. CI is the quality gate, not branch protection.

Consequences. Consistent with the rest of the estate. Depends on the direct-to-main write path tracked in gitea-mcp #35 (item #1).


ADR-010 — Third-party caption acquisition via the timedtext/player baseUrl

Status: Accepted (2026-06-02)

Context. ADR-007 settled captions-first. The first build used the YouTube Data API captions.list + captions.download endpoints to acquire them. A live Stage-0 run proved that captions.download is owner-only: it requires the OAuth identity to own the video, so every subscription video the user does not own returns HTTP 403. Result: 0 summaries produced and a ~150-line error spew. The captions-first decision is sound; only the acquisition mechanism was wrong.

Decision. Acquire captions from the player response + timedtext baseUrl, not the Data API captions endpoints:

  1. POST https://www.youtube.com/youtubei/v1/player with an InnerTube ANDROID client context (no API key, no OAuth). Read captions.playerCaptionsTracklistRenderer.captionTracks[]. Each track carries baseUrl, languageCode, and kind ("asr" = auto-generated).
  2. Select by PreferredLanguages, preferring non-asr when both exist.
  3. GET the track's baseUrl unauthenticated (plain http.Client, no OAuth token attached — the token can break the timedtext endpoint). The ANDROID baseUrl is pinned to fmt=srv3 (timedtext XML); the parser also accepts json3 and the legacy <transcript> XML.

A watch-page scrape of ytInitialPlayerResponse is the documented fallback if InnerTube returns no captionTracks.

Live validation (from koala, 2026-06-02): the ANDROID InnerTube client returned 6 captionTracks with working baseUrls for a public video the run identity does not own, and the unauthenticated baseUrl GET returned real transcript text. ANDROID is the client of record (historically returns baseUrls without a PoToken). The WEB client and watch-page scrape are fallbacks.

Consequences.

  • Works for any public captioned video, not just owned ones — this is the fix for the 403 wall.
  • ToS-grey: youtubei/timedtext are unofficial endpoints. They can break when Google shifts InnerTube client requirements or introduces PoToken gating. Mitigation: degrade, never error — a missing/empty/403/unparseable caption yields domain.Transcript{Source: SourceNone}, so a future breakage produces "no transcript" rather than a crash or error spew. Only genuine transport (network) faults error.
  • No OAuth is needed for the transcript fetch. OAuth is still required for ListSubscriptions and NewVideos (Data API) — only the transcript path goes unauthenticated.
  • Still no Whisper. Speech-to-text stays deferred (ADR-007 unchanged).

Supersedes: the acquisition mechanism of ADR-007 (Data API captions.download → player/timedtext baseUrl) only. ADR-007's captions-first stance and the STT deferral stand.


ADR-011 — Web read-surface at Stage 0: Dex authn (single-user authz), action signal, public ingress + GitOps

Status: Accepted (2026-06-02)

Context. The Stage-0 CLI (list/show) reads summaries but doesn't capture the Stage-0 headline test — whether the maintainer acts on a summary (watches/skips/saves because of it). A browser surface is wanted, and the maintainer chose to deploy it properly: tapir.d-ma.be via the homelab ingress, full k3s/Flux GitOps, with Dex login from the start rather than a Tailscale-only no-auth dev page. The data-model note says "auth is dormant at Stage 0", so logging in early is a deliberate deviation worth recording.

Decision.

  1. Add a Stage-0 web reader (tapir serve, HTMX+Templ) over the existing store — a new transport, not a core change (ADR-003). Pages: summary list + full view + watch/skip/save actions recorded in a new summary_actions table. The action signal instruments the Stage-0 success metric directly.
  2. Authentication via Dex OIDC (ADR-002 already names Dex for identity). Authorization stays trivial: an allowlist of one subject (the maintainer). NO user CRUD, NO per-tenant isolation — that authz/isolation work is the real Stage-1/2 line and stays deferred. The distinction (authn now, authz/tenancy later) is what keeps this honest with VISION.
  3. Deploy at tapir.d-ma.be via the existing homelab pattern: gitea CI (buildah) → registry → Flux reconciling manifests in mathias/infra k3s/apps/tapir/; secrets via ESO + 1Password; postgres18 reached in-cluster. This is application of existing convention, not a new infra decision.

Consequences. The maintainer gets a real, authenticated reading surface and the Stage-0 metric becomes queryable. A browser session-login path now exists (distinct from mcp-chassis's inbound Bearer-JWT validation — not the same code). Wiring login early adds a Dex static-client registration + ingress/TLS as prerequisites. If multiple users ever arrive, authorization/isolation is a new ADR (Stage 1) — this one deliberately does not build it. Full spec: docs/ui-spec.md.


ADR-012 — Open Stage 1: multi-user with enforced isolation (RLS) in the same slice

Status: Accepted (2026-06-03)

Context. ADR-011 shipped a single-user web reader with an allowlist of one, deferring multi-user authz/isolation to "a new ADR (Stage 1)". The maintainer has chosen to open Stage 1 now — multi-user registration, per-user YouTube connect, and account management — ahead of the formal Stage-0 self-use gate. VISION's hard invariant ("data isolation is a promise, not a feature flag … holds from the first user") and the drift signal ("building Stage 1+ machinery before the gate") make one thing non-negotiable: multi-user features must not ship before isolation is enforced.

Decision.

  1. Open Stage 1. Build registration (explicit, not just-in-time: a Dex-authenticated subject with no users row completes a registration step that creates it), per-user web-initiated YouTube OAuth connect (distinct from the CLI tapir auth), and account management (view / disconnect / delete).
  2. Isolation is DB-enforced via Postgres Row-Level Security, not application-layer filtering — realising ADR-002's per-tenant-role intent. RLS is FORCEd on every user-owned table (the app connects as the non-superuser table-owner tapir role, which would otherwise bypass RLS); every request scopes rows via tapir.current_user_id (SET LOCAL inside a transaction), routed through a single structural helper so scoping is not per-query opt-in.
  3. The Stage-2 isolation bar is pulled forward into THIS slice, not deferred. A real isolation test (two users, disposable/embedded Postgres) — user A reads/writes zero of user B's rows across every table — ships green with the multi-user features. No multi-user feature merges ahead of that test passing.

Consequences. Stage 1 + the Stage-2 isolation guarantee land together; isolation is structural (DB), so it cannot be forgotten per-query. Adds video_connections and subscriptions tables, a web OAuth callback, and a registration surface. The single-user allowlist (ADR-011) is replaced by per-subject users rows. Reversible only by a superseding ADR. This deliberately advances ahead of the Stage-0 gate — recorded as the maintainer's 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). The "Tapir never holds write access to the shared identity provider" rationale is partially superseded by ADR-017 (the invite flow now creates Dex Password CRs). The deletion-behaviour decision itself — delete Tapir-side state, leave the Dex identity intact — still stands; ADR-017 only changes the create side, not delete. See ADR-017 for the now-asymmetric posture (Tapir can create Dex accounts but still does not delete them).

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 on deletion. (Note per ADR-017: Tapir does now create Dex Password CRs on invite — so the create and delete sides are deliberately asymmetric, and a deleted user's Dex Password CR persists. See ADR-017 consequences.)

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. ADR-017 widens this gap: Tapir now creates the Dex Password CR but does not delete it, so a deleted Tapir user leaves an orphaned Dex local-password account. Tracked as a known gap in ADR-017.
  • Blast radius: ADR-013 originally claimed "Tapir never holds write access to the shared identity provider." ADR-017 changes this — Tapir now holds scoped create+get on passwords.dex.coreos.com in the auth namespace. The blast radius is no longer zero; it is bounded by that RBAC. See ADR-017 for the security analysis.

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). Partially implemented as of v0.6.0 — see the implementation note at the end; the shared per-egress-IP rate gate (decision item 2) may not be fully realised. Verify against internal/runner + the youtube adapter before treating as done. ADR-018 (in-process scheduled discovery + auto-summarize) makes decision item 2 load-bearing and folds its confirm/finish into that build.

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).

Implementation note (2026-06-03, v0.6.0 — added during reconciliation). A v0.6.0 release shipped 429 handling: domain.SourceRateLimited (429 no longer collapsed to SourceNone), youtube adapter maps 429 → SourceRateLimited, **migration 007 added videos.transcript_status

  • videos.rate_limited_at** (durable per-video retry state), TAPIR_FETCH_BACKOFF config, runner records rate-limited + skips within the backoff window, and a " Retrying later" badge. This realises decision items 1, 3, and 4 well. Item 2 (a single shared per-egress-IP rate gate) appears to be realised as per-video backoff state, NOT a process-wide IP gate — multiple videos can still each hit the endpoint and collectively trip the per-IP 429. Treat item 2 as not yet confirmed done; verify in internal/runner/youtube adapter and, if absent, it remains open. (Resolved during reconciliation: the v0.6.0 report's reference to "migration 008 videos.rate_limited_at" was a mislabel — both columns shipped in migration 007, not a missing 008. No migration was lost; the sequence legitimately skips 008. rate_limited_at exists and the runner's column reads are sound.) ADR-018 makes item 2 a hard requirement: in-process scheduled discovery + auto-summarize drives all users' fetches through one pod egress, so the process-wide gate is confirmed/built as part of that slice.

ADR-015 — Per-user credentials: envelope-encrypted in PG18, not vault-stored

Status: Accepted (2026-06-03)

Context. The Stage-0/1 SecretStore (internal/adapters/secrets/file.go) holds per-user YouTube OAuth refresh tokens as a flat key-value JSON map on a PVC — explicitly a stand-in for "op/ESO later" (ADR-002, ADR-006). infra#86 proposed migrating it to an ESO-backed store. The decision spike (infra#88) found that framing subtly wrong: ESO syncs vault→cluster at deploy/refresh time; it is not a runtime write API. Per-user tokens are written at runtime, per end-user (every YouTube connect; on token rotation) — they are application state, not configuration. The homelab 1Password SA is also read-only, so a vault-write path would require a new write-capable SA, widening Tapir's blast radius to shared estate infra to store what is fundamentally Tapir's own row-data. Reading the actual SecretStore confirmed the shape: a 3-method port (Get/Put/Delete) over opaque refs, written interactively per user.

Decision. Per-user credentials are stored envelope-encrypted in PG18, not in the vault:

  1. Tokens are encrypted with a single app-level envelope key and stored as ciphertext in PG18, under the Row-Level Security already enforced and tested (ADR-012). Reads/writes go through the existing withUser RLS-scoped seam.
  2. The envelope key is the only secret in 1Password — fetched via the existing read-only SA (confirmed working). No new write-capable SA; no per-user vault items.
  3. The ports.SecretStore port is unchanged (Get/Put/Delete). The implementation swaps FileStore (PVC JSON) for a PGStore (encrypted rows). Every consumer — connect, disconnect, delete-account — is untouched (the port abstraction holds, ADR-003 spirit).
  4. Infra/operator credentials (Dex client secret, MCP-auth tokens, service tokens) stay an ESO/1Password concern. This ADR governs per-user runtime credentials only. The two classes use two mechanisms deliberately — because they are two different things (runtime app-state vs deploy-time config), not as a compromise. The "one mechanism" question (maintainer's initial preference) was answered in #88 by correctly classifying the secrets rather than unifying their storage.

Consequences.

  • Runtime credential writes are normal RLS'd DB writes — no ESO sync latency, no indirection, no write-SA blast radius. The interactive connect→store→use flow works without a vault round-trip.
  • Keeps PG18 and keeps ADR-002 intact (Supabase was considered and rejected again in #88 — adding a datastore to hold a few encrypted strings PG18 already holds).
  • Adds an encrypt/decrypt seam and an envelope-key rotation responsibility (re-encrypt the per-user rows under a new key). infra#89 (build) must implement and test rotation, not assume it — this is the real engineering cost of the choice.
  • The vault's involvement shrinks to one static key via the SA already trusted for reads.
  • Supersedes the "PVC stand-in for op/ESO" intent recorded in secrets/file.go and docs/homelab-integration.md for the per-user secret path (the ESO/1Password reference in ADR-006 stands for the infra-cred path).

Reversibility / falsification (from infra#88). Revisit if: per-user tokens need high-frequency rotation writes (weak — PG18 handles it); an estate compliance policy requires all credentials in 1P for a single audit surface (maintainer-knowable, not currently believed to hold — would favour the vault-write path on policy grounds); or envelope-key rotation proves operationally worse than per-secret vault rotation (the real cost #89 must prove). If none hold, this stands. Full reasoning + rejected candidates (write-capable SA; Supabase): the infra#88 decision doc (infra/docs/superpowers/handoffs/). Build + reboot-validation: infra#89.


ADR-016 — Stage 0 gate revised: "useful to me OR a friend", behavioural not feedback

Status: Accepted (2026-06-03). Revises the Stage 0 definition in VISION.md (supersedes the original "useful to me, specifically" gate and folds in the old Stage 1 "a trusted user returns" test). The clock-start is further revised by ADR-018 (starts when scheduled discovery ships, since unprompted use was not possible before then).

Context. The original Stage 0 gate was "the maintainer reads summaries weekly for four weeks and acts on one." The maintainer chose to change it to include friendly users, reasoning that early signal from friendly users is valuable. Two sub-decisions shaped the final form:

  • Me OR a friend (not AND): either the maintainer or an onboarded friend showing use clears it.
  • Behavioural, not feedback: the test is return usage, not stated approval.

Decision. Stage 0 passes when, over a 34 week window, either the maintainer or at least one onboarded friend returns to Tapir unprompted and reads/acts on summaries in ≥2 separate weeks. Friend feedback is gathered and valued but is not the gate.

Why behavioural, not feedback (the load-bearing part). Asked-for feedback from friendly users is the least reliable signal in product development — politeness bias means a friend you onboarded will tend to say encouraging things regardless of real value. The thing actually worth knowing is whether they come back on their own. So the gate measures returns, not nice words. This deliberately resists the most common way a principled gate dies: being declared "passed" on the strength of a polite reaction.

Honest note on what this change does. This is a guardrail edit made while the original gate was unmet (Stage 0 had barely started; build had run well ahead of use-evidence). That is precisely the pattern that warrants scrutiny — redrawing a gate around work already done. It was examined on that basis and proceeds because: (a) the new gate is not softer in kind — it stays behavioural and sustained, merely broadening who can supply the signal; (b) friendly-user signal is genuinely valuable; (c) the politeness-bias guard keeps it from collapsing into "someone said it's nice." It is not a licence to treat the already-shipped Stage-1 machinery as evidence the gate passed — use-evidence remains open.

Consequences.

  • VISION.md Stage 0 rewritten; old Stage 1 ("a trusted user returns") folded in (it was near-identical to the new test); hardening renumbered to Stage 1.
  • New drift signal added: declaring the gate passed on polite feedback rather than return-usage.
  • The 2026-07-01 check-in now asks "is anyone (me or a friend) coming back unprompted?", not "am I using it weekly?". (Check-in date itself revised by ADR-018.)

Reversibility. A superseding ADR could tighten it back to maintainer-only or raise it to require multiple returning users. Recorded with the full rationale (including the self-scrutiny about editing a gate while it's unmet) so the reasoning survives, not just the new wording.


ADR-017 — Invite flow: Tapir creates Dex local-password accounts (write access to the shared identity provider)

Status: Accepted SUPERSEDED by ADR-019 (2026-06-07). The Dex local-password invite provisioning was removed when the homelab IdP migrated Dex→Authentik (infra ADR-0001); Authentik now owns invites. Original record below.

Accepted (2026-06-03), recorded retroactively during reconciliation, then deliberately ratified KEEP (2026-06-03). This capability shipped in v0.7.0 without an ADR — code, RBAC, and a deployed ServiceAccount landed before any decision record existed. This ADR documents what shipped and honestly records that the decision-before-code discipline was not followed here (see "Process note"). Partially supersedes ADR-002 and ADR-013 (the "Tapir holds no write access to the shared identity provider" posture).

Keep-or-reverse, decided (2026-06-03). After the retroactive recording, the maintainer weighed keep vs. reverse (drop the RBAC + invite flow, rely on Google OIDC only). Decision: KEEP. Deciding fact: not all intended Future-B users have / will use Google accounts, so Google OIDC alone cannot onboard them — the invite flow is therefore load-bearing, not a redundant convenience, and reversing it would leave some intended users with no onboarding path. The trust-surface cost (scoped create on passwords in auth) is accepted deliberately in exchange. The opposing argument (this contradicts ADR-015's "no write credential to shared infra" logic, applied there to secrets) was considered and outweighed only because the capability is genuinely necessary for real users here — it is not a precedent for widening the surface further. Explicitly NOT chosen: adding delete to the RBAC to close the orphan gap (that widens the surface in the wrong direction; accept the orphan at Future B instead — see open items).

Context. Stage 1 onboarding needs a way for a friend to get a login. Two paths shipped: Google OIDC via Dex (no Tapir code — Dex handles it), and an invite flow where Tapir itself provisions a Dex local-password account. The invite flow is what this ADR is about.

What shipped (reconstructed from internal/adapters/dex/dex.go, internal/web/invite.go, migration 009_invitations).

  1. tapir invite <email> (host CLI) writes an invitations row: a 32-byte crypto-random, single-use, 7-day-expiry token (the token is the capability), email, used_at. The invitations table is deliberately not RLS/user_id-scoped — the invitee has no user yet and the token itself is the secret. (Sound; documented in the migration.)
  2. Recipient visits /invite/{token} (public, no session), sets a password (validated before the token is consumed, so a typo doesn't burn it), the token is claimed exactly once.
  3. Tapir bcrypt-hashes (cost 12) and POSTs a passwords.dex.coreos.com Custom Resource into the auth namespace via the in-cluster Kubernetes API, authenticating as the tapir ServiceAccount. TLS validated against the mounted cluster CA. Off-cluster (dev) it returns ErrNotInCluster and degrades without consuming the invite.
  4. RBAC applied this deploy: the tapir ServiceAccount has create+get on passwords.dex.coreos.com in the auth namespace (and only that).
  5. Dex (kubernetes storage) then serves local-password login for that email; Tapir's registration gate creates the profile on first login.

Decision (as ratified now). Accept the invite flow as built: Tapir may hold scoped write access to Dex's passwords resource in the auth namespace, for the purpose of invite-based local-account creation. This is a deliberate, bounded reversal of the prior "no write access to the shared identity provider" posture (ADR-002/013).

Why this is acceptable (the case for keeping it).

  • The RBAC is minimally scoped: create+get on one resource type in one namespace, not broad Dex/cluster write. Blast radius is bounded and inspectable.
  • It enables friend onboarding without Google OAuth app verification (ADR-008 deferred that), which is genuinely useful for Future B — and necessary for users who won't use Google (the deciding fact in the keep decision above).
  • The code is careful: validate-before-consume, single-use tokens, graceful off-cluster degrade, sentinel errors mapped to clear messages, the base64-bcrypt storage gotcha handled.

Consequences / known gaps (the case to watch).

  • Reverses a load-bearing principle. ADR-002 and ADR-013 leaned on "Tapir never writes to the shared identity provider" for blast-radius minimisation. That is no longer true. Anyone reading those ADRs without this one would be misled — hence the cross-references added to both.
  • Create/delete asymmetry → orphaned Dex accounts. Tapir now creates Dex Password CRs but (per ADR-013) does not delete them on account deletion. A deleted Tapir user leaves an orphaned Dex local-password account that can still authenticate (though it would hit the registration gate with no profile). This widens the ADR-013 right-to-erasure gap. Open — accepted for Future B, revisit before Future C (do NOT add delete RBAC just to fix this).
  • create on a shared-namespace identity resource is a meaningfully larger trust surface than the rest of Tapir. If the tapir pod is compromised, the attacker can mint Dex local accounts. Bounded by the namespace/resource scope, but real — worth a deliberate look before Future C.
  • Token custody for invites is in PG (invitations.token) in plaintext; single-use + 7-day TTL bound the exposure, but a DB read yields live invite tokens until claimed/expired.

Process note (why this ADR is retroactive). This capability was built and deployed by parallel agent sessions while the main planning thread was elsewhere, and shipped with no ADR — the first time in this project a guardrail-reversing change skipped the decision-before-code discipline. Recorded here not to rubber-stamp it but to restore the audit trail: the decision is now visible, its principle-reversal is named, and its open gaps (orphaned accounts, the larger trust surface) are tracked rather than buried in a release note. The maintainer ratified KEEP after the fact (see status block) on the deciding fact that some intended users cannot use Google OIDC.

Open items this ADR creates (tracked in infra):

  • Confirm the RBAC really is create+get only (not broader) against the deployed manifest in infra — a security claim currently resting on a sibling report, not a verified manifest read.
  • Accept the orphaned-Dex-account gap for Future B; revisit (orphan cleanup + the whole Dex-write surface) before any Future C move. Do not add delete RBAC solely to fix the orphan.
  • Review the larger trust surface before any Future C move.

ADR-018 — Make Tapir usable unprompted: in-process scheduled discovery, auto-summarize default, gate-clock reset

Status: Accepted (2026-06-03)

Context. The Stage-0 gate (ADR-016) measures whether the maintainer or a friend returns and reads/acts over weeks. But the system could not actually be used that way: discovery (tapir run) was host-side manual, so a newly onboarded user saw an empty list and had no reason to return. The gate was structurally unmeetable — not because the product failed, but because the workflow depended on the maintainer SSHing in to trigger each pass. The runner already has a Loop(ctx, interval) (run-on-start, then every tick) and per-user mode/dedup/backoff; what was missing was invocation — nothing called it for the deployed users on a schedule.

Decision.

  1. In-process scheduled discovery. tapir serve launches a background goroutine that runs discovery for all users on an interval (TAPIR_DISCOVERY_INTERVAL; unset/0 = off). It enumerates users (un-RLS'd user_identities) and runs each user's pass inside withUser, reusing the existing runner — not a new scheduler. Stateless timing; ctx-cancellable; per-user failures isolated.
  2. Auto-summarize default ON for Future-B users (new registrations default true; existing rows updated), so discovery both populates and summarizes — the list fills itself. The per-user manual toggle remains.
  3. Gate-clock reset. The Stage-0 34 week window (ADR-016) starts when this ships, because unprompted use was impossible before it. This is starting the clock when the experiment can actually run, not a reset to dodge a failing gate (the prior window measured nothing — there was no way to use the system unprompted). The 2026-07-01 check-in moves accordingly to ~34 weeks after this deploys.

Why in-process and not a k8s CronJob. Maintainer's call: simpler deploy (no second deployable), acceptable at Future-B scale (13 users). The CronJob's advantage — failure isolation between discovery and the web/read path — was weighed and traded away knowingly.

Consequences / constraints.

  • Discovery shares the web process's lifetime and egress. A wedged discovery pass can degrade the reading UI (the coupling a CronJob would have avoided). Accepted at this scale.
  • SINGLE-REPLICA ASSUMPTION (load-bearing). If tapir serve ever runs >1 replica, every replica runs the discovery loop → every user fetched in parallel (429s + duplicate work). Tapir must stay single-replica while in-process scheduling is enabled, or this is revisited (move to CronJob, or add leader-election). Recorded so a future scale-up doesn't silently double-run.
  • Makes ADR-014 item 2 (shared per-egress-IP rate gate) load-bearing. In-process + auto + multi-user drives all caption fetches through one pod egress, concurrent with click-path Summarize. The build confirms/finishes the process-wide rate gate in the same slice; without it the system self-inflicts 429s every cycle. Auto-summarize ON is gated on the rate gate existing (fallback: ship discovery with auto OFF until it does).

Reversibility. Disable via TAPIR_DISCOVERY_INTERVAL=0 (reverts to manual tapir run). Moving to a CronJob later is a superseding ADR; the per-user runner is unchanged either way. Spec: docs/specs/scheduled-discovery.md.


ADR-019 — Authentik owns invites; Tapir stops provisioning accounts

Status: Accepted (2026-06-07). Supersedes ADR-017 (Dex local-password invite provisioning).

Context: infra ADR-0001 migrated the homelab IdP Dex→Authentik. Authentik provides first-class invite flows; the Dex local-password connector never consulted the Password CRs Tapir wrote (the defect that triggered the migration). Tapir-web's OIDC issuer now points at Authentik (infra ADR-0001 step 3).

Decision: Tapir no longer provisions accounts. The Dex-password invite path is removed: internal/adapters/dex, the public /invite/{token} set-password UI (internal/web/invite.go), the tapir invite CLI (cmd/tapir/invite.go), the InvitationStore/DexPasswordCreator ports + App.Invitations/App.Dex wiring, the invite Templ pages, and the tapir invite Taskfile target. New users are invited via Authentik's flow, log into Tapir via OIDC, and are captured by Tapir's existing provider-agnostic /register (display name). Login + Google moved by config only (Authentik per-app issuer); the OIDC adapter is unchanged.

Consequences: smaller Tapir blast surface — no writes to the shared identity provider, no configmap/CR access, the dedicated passwords.dex.coreos.com RBAC + ServiceAccount are removed (infra side, coupled change). The invitations table (migration 009) is left in place — migrations are append-only and the unused table is harmless; a future migration may drop it. The *_DEX_* config/identity names (TAPIR_DEX_CLIENT_*, dex_subject, the DexAuth/oidc package) are now misnomers; renaming is deferred (cosmetic, not behavioural).

Rejected: keeping Tapir's /invite UI but calling Authentik's API on claim — couples Tapir to Authentik's admin API + a token for no real gain; Authentik's own invite flow is the supported path.


ADR-020 — Recency-bounded auto-summarize + honest sparse-state surface

Status: Accepted (2026-06-08). Refines ADR-018 (auto-summarize) and ADR-014 (per-IP caption rate gate).

Context. Tapir is operational but sparse: at real subscription volume the maintainer's account holds ~283 discovered videos, ~15 summarized, ~256 behind the respected per-IP caption rate gate, ~12 no-captions. Two problems follow. (1) Load: ADR-018 auto-summarizes every unseen video, so a large back-catalogue re-drives the whole queue through the gate every cycle — self-inflicted 429s with no user value (nobody is waiting on a 6-month-old video). (2) First contact: a new user sees a mostly-empty feed with no moving parts and a UI that implied abundance/imminence ("fetching soon" ×256, "Run tapir run", "Summarize now"); the Stage-0 gate is return usage, and the experience died at the first visit. Source: a UX heuristic review (docs/ux-review/UX-REVIEW-stage0-recency.md).

Decision.

  1. Recency bound on auto-summarize. In automatic mode the scheduler only summarizes videos published within TAPIR_AUTO_SUMMARIZE_WINDOW (default ~7d). Older videos are still discovered and listed but not auto-processed — they keep the manual "Summarize" affordance. An explicit manual request bypasses the bound. 0 disables it (pre-recency behaviour). This bounds auto load; it does not fetch harder — the gate (ADR-014) is untouched and the manual path still serialises through it.
  2. Honest sparse-state surface. Copy is reframed to surface scarcity truthfully, never to look fuller: "N ready · M in queue · K no captions" (not "fetching soon"); a one-line "captions are fetched slowly on purpose" note; "Summarize" (not "Summarize now"); the empty-connected state stops printing an impossible CLI command.
  3. Feed IA = one list, noise-collapsed. Summarized + recent un-summarized cards lead inline; the older un-summarized back-catalogue collapses behind a single "Show N older videos" disclosure; caption-less videos collapse to a one-line count instead of N dead cards. List is ordered summarized-first, then published_at DESC NULLS LAST.

Consequences. The auto path's per-cycle fetch volume is bounded by recent uploads, not the whole back-catalogue, so steady-state 429 pressure drops sharply. Older videos become explicitly on-demand — a deliberate honesty trade (the user chooses to spend a scarce fetch on old content). The single-replica assumption (ADR-018) is unchanged.

Deliberately NOT done (premature until the Stage-0 loop is validated). Return-nudges (digest email / push) — a nudge contaminates the unprompted-return signal the gate measures (ADR-016); building it now poisons the experiment. Also deferred: full-text search, channel facets, read/unread, saved views — all need summary abundance to matter.

Reversibility. TAPIR_AUTO_SUMMARIZE_WINDOW=0 restores summarize-every-unseen; the feed collapse keys off the same window (App.RecencyWindow=0 → everything inline).


ADR-021 — Persist transcripts as shared, video-keyed public content (re-analysis never re-fetches)

Status: Accepted (2026-06-09). Reopens the transcripts half of the "Global cross-tenant videos/transcripts table" rejection (data-model.md). Builds on ADR-007 (captions-first), ADR-010/ADR-014 (the per-IP caption rate gate), and ADR-012 (per-user RLS isolation).

Context. Every summarization fetches the transcript fresh through the caption path, even when the exact same transcript was fetched moments ago — for the same user re-summarizing, or for a second user who happens to watch the same video. The caption fetch is the one genuinely scarce, genuinely risky operation in the system: YouTube's timedtext endpoint is unofficial and per-IP rate-limited (ADR-010), and tripping it risks the maintainer's Google standing (ADR-014). So the operation we most want to avoid repeating is the one we currently repeat unconditionally. A transcript is public content — the same words YouTube serves to anyone — and carries nothing user-identifying. The per-user isolation that protects summaries, feeds, and tokens (ADR-012) is the wrong shape for it: it forces a re-fetch per user for data that is identical across users.

The original rejection ("Global cross-tenant videos/transcripts table") bundled videos and transcripts together and rejected both on the grounds that "at 15 users, re-summarizing is cheaper than the coupling." That reasoning holds for videos (per-user feed rows, genuinely user-scoped) but not for transcripts: the cost being avoided is not LLM re-summarization, it is a rate-gated, reputation-risky network fetch, and that cost is paid per re-fetch regardless of user count. One re-fetch avoided is strictly worth more than the coupling it removes.

Decision.

  1. A single shared transcripts table, keyed by the cross-user dedup key (provider, provider_video_id) — the stable public identity of the video, not Tapir's internal per-user videos.id. Columns: the key, source (captions/none), language, content, fetched_at. It holds only public caption content + the video's public id — nothing user-identifying — and is therefore NOT RLS-scoped: no user_id, no policy, no FORCE ROW LEVEL SECURITY. This is the deliberate, single exception to the ADR-012 isolation boundary, and the only one.
  2. Summarize path becomes read-stored-first. Have a stored transcript for this video? → summarize from the stored text, no caption fetch. No stored transcript? → fetch through the unchanged gate (ADR-014) → store it → summarize. The gate is neither bypassed nor weakened; persistence reduces how often we reach it, never how fast.
  3. De-facto cross-user dedup is the intended behaviour, not a feature with a switch. Two users who share a video share the one transcript row. A permanent source = 'none' (no captions) is stored too, so a known-caption-less video is not re-fetched by anyone. A transient 429 (SourceRateLimited) is never stored as terminal — it stays a per-user retry via the existing transcript_status backoff (ADR-014), so persistence cannot mask a rate-limit into a false "no transcript."
  4. Per-user summaries stay RLS-scoped (ADR-012 unchanged) and reference the transcript by video id. Videos stay per-user. Only transcripts go shared.

Consequences. Re-analysis (re-summarize, different model, paste of an already-seen video, onboarding of a second user with overlapping subscriptions) never re-touches YouTube — the primary win, and it reduces aggregate caption-gate pressure, reinforcing ADR-010/ADR-014 rather than straining them. The isolation surface gains exactly one non-RLS table; an isolation test asserts the boundary is exactly there and has not leaked to any user-owned table (this is the proof the public-content classification was implemented as designed). It also unblocks multi-model / customizable analysis (re-run analysis on stored text for free) — enabling that is this ADR's point; building it is separate.

Reversibility. The read-stored-first check is the only behavioural coupling; removing it restores fetch-every-time. The down-migration recreates the per-user RLS-scoped transcripts shape (001/003). No user-facing surface depends on cross-user sharing — sharing is the storage shape, never exposed in the UI.


ADR-022 — Summarizer is a resilient endpoint chain, not a single model

Status: Accepted (2026-06-10). Extends ADR-004 (the copied llm Primary→Fallback routing). Triggered by the first friendly-pilot live run, where a connected user got zero summaries after 12h.

Context. Stage-0 ran a single summarizer model (koala/phi4-mini) with no fallback wired (summarizer.New(primary, nil)). The live run exposed three independent failure modes, each of which silently produced no summary:

  1. Context overflow. phi4-mini has an 8k context. Real transcripts (one was 11,602 tokens) exceed it and the gateway returns HTTP 400 — and the request also sent max_tokens=8192, so even a short transcript plus the completion budget could overflow the window.
  2. Malformed model output. phi4-mini intermittently emits highlights as a bare string instead of an array, producing cannot unmarshal string into []string. The old code returned the parse error without trying any other model — a 200-with-bad-JSON short-circuited.
  3. No fallback existed at all — any primary failure was terminal for that video.

phi4-mini is kept as primary deliberately: it is fast and, on transcripts that fit, correct. The fix is resilience around it, not replacing it.

Decision.

  1. Ordered endpoint chain (summarizer.NewChain). Endpoints are tried in order; the first to return a parseable summary wins. Default chain: koala/phi4-mini (primary, local) → koala/phi4-14b (fallback, local) → berget/mistral-small (worst-case, external). All three are reached through the one LiteLLM gateway by alias — the gateway already fronts both llama-swap and berget — so a fallback is a different alias, not a second client config.
  2. A parse failure advances the chain, same as a transport error. "Reliably summarized" means parseable summary returned, not HTTP 200. This is the behaviour the old Primary→Fallback shape missed.
  3. Tolerant parse. highlights/takeaways coerce from a bare string (or a mixed scalar array) to []string, so the most common small-model quirk is absorbed without spending a fallback round-trip — keeping the fast path fast.
  4. Transcript truncation (TAPIR_MAX_TRANSCRIPT_CHARS, default 18000). Input is bounded up-front to fit a small-context primary, so overflow is prevented rather than recovered-from.
  5. Bounded completion budget (TAPIR_SUMMARY_MAX_TOKENS, default 1500). A summary needs few hundred tokens; the old 8192 budget itself contributed to 8k-window overflow.

Local-first guarantee preserved. The chain ordering is the guarantee: locals are tried first, so content reaches the external endpoint only after every local endpoint has failed. TAPIR_CLOUD_FALLBACK_MODEL="" removes the external endpoint entirely — the lever a client/NDA deployment pulls so content never leaves the local stack. With no external endpoint configured the ai_routing.feature "content only local" scenarios hold unchanged.

Reversibility. Pure wiring + config. Setting TAPIR_FALLBACK_MODEL and TAPIR_CLOUD_FALLBACK_MODEL empty collapses the chain back to single-primary behaviour; the tolerant parse and truncation are strict supersets of the old behaviour (a previously-parseable reply still parses; a transcript within budget is unchanged).


Rejected alternatives

Approaches considered during the 2026-06-02 planning + grill session and deliberately not taken. Recorded so a later session doesn't re-propose them as if they were fresh ideas. Each maps to the ADR that settles it.

Rejected Why rejected Settled by
Python / FastAPI / arq stack Would be the only non-Go service in the estate, outside every shared chassis/convention/CI pattern ADR-001
Self-hosted Supabase (auth + RLS + Vault) Duplicates Dex, ESO+1Password, and the postgres instance being actively de-coupled; three new things to run and back up ADR-002
Living inside the hyperguild/ingestion monolith The only worthwhile reuse is ~100 lines (llm) that get copied anyway; standalone-first wants the service to owe the monolith nothing ADR-003, ADR-004
Lifting shared packages into a brain-common module Couples Tapir's release cycle to the monolith for negligible code savings ADR-004
Importing/replicating the filesystem brain package Assumes co-location with the brain git checkout; wrong for a standalone networked service ADR-005
Reusing ingestion's oauth package for YouTube/Vimeo Same name, opposite direction — it's inbound MCP-server auth, not outbound provider OAuth ADR-006
Global cross-tenant videos/transcripts table (dedup) Reintroduces the cross-domain DB coupling the homelab review is removing; at 15 users, re-summarizing is cheaper than the coupling. Transcripts half reopened by ADR-021 — the avoided cost there is a rate-gated, reputation-risky caption fetch, not LLM re-summarization, so it outweighs the coupling; videos stay per-user. data-model.md, ADR-021 (transcripts only)
Audio-download + Whisper STT in the core path ToS-grey, breakage-prone (yt-dlp), contends for koala GPU with the JEPA PoC; captions alone test the core hypothesis ADR-007
Building multi-tenant SaaS / Google OAuth verification now "Real users soon" was lowered to Future B; SaaS machinery before the Stage 0 self-use gate is the primary documented anti-goal ADR-008, VISION
Delegating the S5 reuse spike to an agent swarm A 1-hour sequential read-and-judge with a single coupled conclusion; orchestration overhead exceeds the work, and it's Diamond-1 judgment the maintainer wanted to own (process note)
Vault-write SA for per-user OAuth tokens (ESO as runtime write path) ESO syncs vault→cluster at deploy time, not a runtime write API; a write-SA widens blast radius to shared infra to store app row-data ADR-015, infra#88
Supabase for per-user credential storage Adds a second datastore for a few encrypted strings PG18 already holds; reopens ADR-002 ADR-015, infra#88
Feedback-based Stage 0 gate (friends saying it's useful) Politeness bias makes asked-for feedback the least reliable signal; return-usage is the real test ADR-016
Reverse the Dex-write invite flow (Google OIDC only) Some intended Future-B users won't use Google; OIDC-only leaves them with no onboarding path — invite flow is load-bearing ADR-017
k8s CronJob for scheduled discovery (vs in-process) At Future-B scale the in-process scheduler is simpler to deploy; CronJob's failure-isolation benefit was weighed and traded away knowingly (revisit if >1 replica or load grows) ADR-018

If a future case genuinely reopens one of these, that's a new ADR superseding the relevant one — not a silent reversal.