# Tapir — Architecture (C4 + sequences)
Diagrams describe **current assumptions**, not a frozen design. They are diffable Mermaid
so they live in version control and render in Gitea. When a decision here changes, record it
in `DECISIONS.md` and update the relevant diagram in the same commit.
Altitude: C4 Level 1 (Context) and Level 2 (Container), plus sequence diagrams for the two
behaviours that carry the most design risk. Clean Architecture layering is described after
the diagrams.
---
## C4 L1 — System Context
Who and what Tapir talks to. (Brain and external AI are dashed — both optional.)
```mermaid
graph TB
user["User
(maintainer; later, trusted users)"]
tapir["Tapir
watches subscriptions,
summarizes new videos"]
yt["YouTube
(Data API + captions)"]
vimeo["Vimeo
(API + text tracks)"]
local["Local AI stack
(LiteLLM / piguard alias)"]
byo["User's BYO AI
(Claude / OpenAI / Gemini)"]
brain["brain
(brain-mcp)"]
user -->|connects accounts,
reads summaries| tapir
tapir -->|subscriptions,
new-video events,
captions| yt
tapir -->|subscriptions,
text tracks| vimeo
tapir -->|summarize
PRIMARY| local
tapir -.->|summarize
FALLBACK, opt-in| byo
tapir -.->|optional sink:
brain_ingest| brain
classDef opt stroke-dasharray: 5 5;
class byo,brain opt;
```
---
## C4 L2 — Containers
Inside Tapir. The **engine** is provider- and sink-agnostic; everything external is an
adapter behind an interface (Clean Architecture ports & adapters).
```mermaid
graph TB
subgraph tapir["Tapir (Go)"]
http["tapir serve
(HTMX+Templ web surface:
read summaries, connect,
account, summarize)"]
watcher["Watcher
detects new videos
(WebSub + poll)"]
engine["Summarization engine
(use-case core)"]
resolver["Transcript resolver
(captions-first)"]
router["AI router
(llm.Router:
Primary -> Fallback)"]
store[("User store
(Postgres,
per-user isolated)")]
subgraph sinks["Sink adapters (Sink interface)"]
sink_store["Store sink
(primary)"]
sink_brain["Brain sink
(HTTP brain-mcp)"]
end
subgraph providers["Provider adapters (VideoSource interface)"]
p_yt["YouTube adapter"]
p_vimeo["Vimeo adapter"]
end
end
http --> store
watcher --> p_yt
watcher --> p_vimeo
watcher -->|NewVideo event| engine
engine --> resolver
resolver --> p_yt
resolver --> p_vimeo
engine --> router
engine --> sink_store
engine -.-> sink_brain
sink_store --> store
classDef opt stroke-dasharray: 5 5;
class sink_brain opt;
```
**Interfaces that keep the engine pure (the ports):**
- `VideoSource` — list subscriptions, detect new videos, fetch transcript. Implemented by
YouTube and Vimeo adapters.
- `Summarizer` — turn a transcript + context into highlights/takeaways. Backed by the
`llm.Router` (Primary local, Fallback BYO).
- `Sink` — deliver a summary. Implemented by the store sink (primary) and brain sink
(optional). New sinks add an implementation, nothing else.
- `SecretStore` — fetch/store per-user OAuth tokens and BYO keys. Backed by ESO/1Password.
The engine depends only on these interfaces, never on YouTube, brain, or a concrete model.
This is what makes "standalone vs homelab" a configuration of which adapters are wired, not
two codebases (ADR-003).
---
## Web surface — `tapir serve` (Stage 1, ADR-011 → ADR-012)
A later transport added over the **unchanged** engine/ports/sinks core (ADR-003): `tapir serve`
is an HTMX+Templ reader/writer (`internal/web`) over the existing `store`. It added no business
logic to the engine — it reads the store and, for one action, kicks the existing engine. ADR-011
shipped it single-user; ADR-012 opened multi-user with DB-enforced (RLS) isolation.
```mermaid
graph TB
browser["Browser
(Dex-authenticated user)"]
subgraph web["internal/web (tapir serve)"]
oidc["oidc
Dex OIDC session
(authenticate-only)"]
gate["registration gate
new subject -> /register"]
pages["summary list + detail
(read) + actions"]
connect["/oauth/youtube/callback
per-user token connect"]
account["account
(disconnect, delete)"]
summarize["Summarize button
-> background goroutine"]
end
store[("store
(Postgres, RLS per user)")]
engine["Summarization engine
(unchanged core)"]
secrets["SecretStore
(per-user token refs)"]
browser --> oidc
oidc --> gate
gate --> pages
pages --> store
connect --> secrets
connect --> store
account --> store
account --> secrets
summarize -->|background| engine
summarize -->|HTMX status poll| store
engine --> store
```
- **Dex OIDC session layer** (`internal/web/oidc`) — **authenticate-only** (ADR-012). It proves
*who*; authorization/isolation is the DB's job (RLS), not the session's.
- **Registration gate** — a Dex subject with no `users` row is routed to `/register`, which
creates the `users` row + the `user_identities` mapping (migration 004). Returning subjects
pass straight through.
- **Web-initiated YouTube connect** — `/oauth/youtube/connect` → `/oauth/youtube/callback`
persists a **per-user** refresh-token ref (`youtube//refresh_token`) via `SecretStore`
and a `video_connections` row (ADR-006, migration 005). Distinct from the CLI `tapir auth`.
- **Account management** — `/account` offers disconnect and **delete account**. Delete removes
only Tapir-side state (cascade across the user's tables + secret refs); the shared Dex identity
is left intact (ADR-013).
- **Immediate summarization** — the web "Summarize" button (`POST /v/{id}/summarize`) fires the
engine in a **background goroutine** inside `serve`; the page HTMX-polls `/v/{id}/status`,
showing a Charmbracelet spinner while in-flight (and an honest "queued/waiting" state under
rate-limiting — ADR-014).
- **Summarization mode** — `users.auto_summarize` (migration 006). Default is **true** for new
users (migration 011, ADR-018); all existing rows were back-filled via migration 012. Auto:
every new video is summarized. Manual: new videos appear unsummarized; the button sets
`videos.summarize_requested`, which the next `tapir run` processes and clears. Both the click
path and the batch `tapir run` drive the same unchanged engine.
The engine, ports, and sink adapters are **untouched** by all of the above — the web surface only
reads the store and triggers the existing engine. Adding it changed wiring, not the core (ADR-003).
---
## In-process scheduler (ADR-018)
`cmdServe` launches a background goroutine when `TAPIR_DISCOVERY_INTERVAL > 0`. On each tick
it calls `store.ListAllUsers` (un-RLS'd admin query), builds a per-user `runner.Runner`, and
calls `RunOnce` for each registered user in sequence.
```mermaid
sequenceDiagram
participant S as Scheduler goroutine
participant DB as Postgres (RLS)
participant YT as YouTube timedtext
participant LLM as LiteLLM gateway
loop every TAPIR_DISCOVERY_INTERVAL
S->>DB: ListAllUsers() [un-RLS'd]
loop per user
S->>DB: GetAutoSummarize(userID)
S->>YT: ListSubscriptions + NewVideos
Note over S,YT: WaitFetchGate(ctx) throttles
all fetches to TAPIR_FETCH_RATE
alt transcript available
S->>LLM: Summarize
S->>DB: Deliver(summary)
else 429
S->>DB: SetTranscriptStatus(rate_limited)
end
end
end
```
**Single-replica constraint (load-bearing).** The scheduler lives in the web process;
`replicas: 1` in the k3s deployment manifest is not cosmetic — running `tapir serve` at
>1 replica makes every replica run the full discovery loop, causing every registered user
to be fetched in parallel from the same egress IP (429s + duplicate work). Do not scale
`serve` past 1 replica without first moving discovery to a k8s CronJob or adding leader
election. The process logs a `Warn` at startup when scheduled discovery is enabled as a
reminder.
---
## Process-wide timedtext rate gate
**`internal/adapters/youtube/gate.go`** (ADR-014 item 2): a single `rate.Limiter`
(`golang.org/x/time/rate`) shared across **all** Adapter instances. Every `httpDo` call for
a caption fetch passes through `WaitFetchGate(ctx)` before hitting YouTube. This serialises
the scheduler loop AND the web click-path through the same per-egress-IP budget. Configured
via `TAPIR_FETCH_RATE` (Go duration, default `2s`). Setting it to `0` disables the gate
(dev/tests only).
This is the precondition that makes scheduled auto-summarize safe: without the gate, a
multi-user scheduler pass could fire many concurrent timedtext requests from the same IP
within seconds, triggering 429s for all users.
---
## Sequence — core use case: new video summarized
```mermaid
sequenceDiagram
participant Src as VideoSource
(YouTube/Vimeo)
participant W as Watcher
participant E as Engine
participant R as Transcript resolver
participant AI as AI router
participant S as Sink(s)
Src->>W: new upload (WebSub push / poll)
W->>E: NewVideo{user, channel, videoID}
E->>R: resolve transcript(videoID)
R->>Src: fetch captions
alt captions available
Src-->>R: captions text
R-->>E: Transcript{source: captions}
E->>AI: summarize(transcript, userContext)
AI-->>E: Summary{highlights, takeaways}
E->>S: deliver(summary)
S-->>E: ok
else no transcript
Src-->>R: none
R-->>E: NoTranscript
E->>S: deliver(skipped: no transcript)
end
```
---
## Sequence — AI routing (local-first, BYO fallback)
```mermaid
sequenceDiagram
participant E as Engine
participant R as llm.Router
participant P as Primary
(local: LiteLLM/piguard)
participant F as Fallback
(user BYO key)
E->>R: summarize(transcript)
R->>P: complete(prompt)
alt local succeeds
P-->>R: summary
R-->>E: summary (fallback_used = false)
else local fails (error/timeout/unavailable)
P-->>R: error
alt user has BYO configured
R->>F: complete(prompt)
F-->>R: summary
R-->>E: summary (fallback_used = true)
else no BYO
R-->>E: error (queued for retry)
end
end
```
> "Reliably" (VISION / the BYO trigger) is operationalized as: **Primary returned without
> error within timeout.** Richer quality scoring can layer on later without changing the
> interface. `fallback_used` is recorded per summary so "is the local stack good enough?"
> becomes a query, not a guess.
---
## Clean Architecture layering
```mermaid
graph LR
subgraph domain["Domain (entities)"]
d["User, Subscription, Video,
Transcript, Summary"]
end
subgraph usecase["Use cases (engine)"]
u["SummarizeNewVideo,
ConnectAccount,
RouteAI"]
end
subgraph ports["Ports (interfaces)"]
po["VideoSource, Summarizer,
Sink, SecretStore"]
end
subgraph adapters["Adapters (infra)"]
a["YouTube, Vimeo, llm.Router,
Store sink, Brain sink,
ESO secret store, HTTP, Postgres"]
end
a --> po
po --> u
u --> d
```
Dependencies point inward only. Domain knows nothing of YouTube, brain, Postgres, or any
model. Adapters are swappable; tests target the use-case core through fake adapters (see the
Gherkin features in `docs/use-cases/`).
---
## Out of scope in these diagrams (deferred per ADRs)
- Audio-download + speech-to-text resolver (ADR-007) — would be an additional `VideoSource`
fallback path, drawn when built.
- Per-user isolation is **live, not deferred**: Postgres RLS `FORCE`d on every user-owned table
(ADR-012, migration 003), realising ADR-002's per-tenant intent at the DB layer. The coarser
multi-tenant primitives (per-namespace NetworkPolicy, Kyverno, tenant label) remain a
Stage-2 hardening item, not exercised yet.
- Public SaaS surface (sign-up, billing) — Future C, not built (ADR-008).