| name | heater-appb-two-dbs-tenancy |
| description | Use when storing per-user state, resolving "my team" / viewer identity in HEATER App B, touching api/stores/* or api/tenancy.py, seeing HTTP 409 TEAM_NOT_LINKED or 401 "Login required.", adding admin endpoints, or tempted to write API/user data into data/draft_tool.db (or engine data into api_state.db). |
Two Databases + Tenancy — who owns what, who sees what
The DB boundary (hard rule)
data/draft_tool.db (HEATER_DB_PATH): engine data. The background scheduler is the SOLE writer; API request paths only read. Access via src.database.get_connection() (WAL, 60s busy_timeout).
data/api_state.db (HEATER_API_DB_PATH): API-owned identity/billing/tenancy. Tables: api_users, api_subscriptions, api_saved_prompts, api_leagues, api_memberships, api_processed_events.
New API-side persistence = a new store in api/stores/ copying the existing pattern: Protocol + InMemory fake + Sqlite impl (WAL, 60s timeout, idempotent CREATE TABLE, thread-locked), plus a DI provider in api/deps.py. Never a raw table in draft_tool.db — that DB belongs to the engines and the single writer.
Viewer resolution (api/tenancy.py)
ViewerContext.effective_team(fallback) has exactly three states (tenancy.py:62-73):
- assigned → the membership team (client fallback IGNORED);
- authed + unassigned (
user_id set, no team) → None — NEVER the fallback; this closes cross-team exposure. Team-required endpoints convert via resolve_required_team(ctx, fallback) → 409 TEAM_NOT_LINKED (tenancy.py:76-87) so the frontend shows "not linked yet", not another team's data;
- dormant (no identity, Clerk off) → the endpoint's query-param fallback = today's open behavior, byte-for-byte.
require_viewer_context (tenancy.py:90-116) also carries the activation flip: once clerk_configured() is true, a request with no/invalid token → 401 "Login required." (lines 100-108). Dormancy and auth share ONE predicate (api/auth.py:166 clerk_configured) so they cannot disagree.
optional_app_user (api/identity.py:36-57) is the non-raising identity for open reads: no header → None; invalid token → None (a bad token must not 401 an open read while dormant); valid token → get-or-create AppUser in api_state.db.
The failure this design exists to prevent
2026-06-01 (Streamlit era, same class): a per-user resolver EXISTED but no page called it — every leaguemate saw Team Hickey's data; per-unit tests stayed green. App B repeats were caught since: api/routers/schedule.py trusted a client team_name query param until commit 0418c89 wired require_viewer_context/effective_team; the frontend hardcoded "Team Hickey" for Standings/Trades until f2decf7 (web viewer-team.ts cache fed by fetchMyTeam). Rule: any endpoint returning team-scoped data takes the ViewerContext and resolves through effective_team/resolve_required_team — a client-supplied team name is only ever the dormant fallback. Guard-test the 409 path (tests/api/test_api_tenancy_resolver.py shows the fake-stores pattern: InMemory user/league/membership stores + fake verifier → assert assigned team beats the query param).
Admin surface
api/routers/admin.py is deny-by-default: caller's Clerk user id must be in HEATER_ADMIN_CLERK_IDS. Team linking = POST /api/admin/assignments (writes api_memberships). Owner rule: assigning leaguemates is the LAST launch step (each must sign in first so their Clerk id exists); an un-linked login correctly resolves to no team (409), not empty defaults.
When NOT to apply
League-wide, non-personalized reads (standings, leaders, research) need only require_login, not viewer context. Engine-side data modeling belongs to src/database.py + the scheduler (don't add API stores for engine data). Multi-league tagging (league_id on engine tables) is owner-gated M4 — don't start it.