ADR-031: Public Demo Operations
Date: 2026-07-12
Status
Section titled “Status”Accepted
Context
Section titled “Context”The demo application (ADR-024) is going public: the repo self-deploys its own demo instance (ADR-025’s Fly.io worked example), which anonymous guests can write to. That raises three operational questions the ADRs don’t yet answer: how the demo stays current between tagged releases, how demo data is seeded and kept clean under open anonymous writes, and how none of this leaks into what a template cloner inherits. The hard constraint: nothing deployment-specific may be hardcoded in workflows — cloners must inherit only generic, parameterized machinery that stays inert until they add their own secrets.
Decision
Section titled “Decision”- Continuous deploy on merge (
.github/workflows/deploy.yml). Every push to the default branch runs migrations (whenSUPABASE_DATABASE_URLis set) andflyctl deploy --remote-only. The app name comes fromfly.toml; the workflow is gated on theFLY_API_TOKENsecret via a gate job (the secrets context is unavailable in job-levelif:), so a clone without the secret sees a skipped job. Tagged releases keep the careful path (release.yml: budget-checked image, mirror, deploy). - Seed and reset are Taskfile targets hard-gated on
DEMO_MODE=1.task demo:seedloadssql/demo/seed.sql, which re-includes the idempotent quiz-question seed migration (one source of truth, no copied fixtures).task demo:resetrunssql/demo/reset.sqlthen re-seeds. Both refuse to run withoutDEMO_MODE=1in the environment, so they are impossible to trigger against a real deployment by accident. - Nightly reset via scheduled workflow (
.github/workflows/demo-reset.yml), double-gated: the job only runs when theSUPABASE_DATABASE_URLsecret is present and the repo variableDEMO_MODEis1, and the Taskfile precondition re-checksDEMO_MODEat execution. - Division of labor with the reaper. The reset deletes only anonymous users’ content (
flashcards,quiz_attempts). Identity deletion stays with the TTL reaper (ADR-024), which owns both the GoTrue auth record and theusersrow — deletingusersrows in SQL would orphan the auth-side records the reaper finds through them. The demo setsGUEST_TTL=48hinfly.tomlso identity cleanup runs on the same rhythm as the nightly content reset. Registered (upgraded) accounts are never touched — keeping progress is the upgrade flow’s promise.
Abuse surface inventory (what going public opens)
Section titled “Abuse surface inventory (what going public opens)”- Anonymous flashcard writes (
POST /learn/flashcards): arbitrary user text, capped at 500 chars/field, RLS-scoped so only the author sees it, templ-escaped on render. Bounded by the stricter/learnrate-limit tier and the request-body cap; the nightly reset bounds accumulation. Accepted. - Anonymous identity creation: every new visitor mints a GoTrue user. Rate-limited; reaped after
GUEST_TTL. Accepted. - Open email signup via the upgrade flow: lets anyone use the demo’s Supabase project to send confirmation emails. Mitigated by rate limits and Supabase’s own email throttling; residual risk accepted for the demo — a fork running real traffic should reconsider.
- No file uploads, no publicly-visible shared writes — quiz questions are read-only under RLS, so cross-user vandalism has no surface.
Consequences
Section titled “Consequences”- The public demo is self-healing: vandalized or bloated guest content lives at most one day; identities at most 48h idle.
- A cloner inherits three inert workflows; adding
FLY_API_TOKEN(and optionallySUPABASE_DATABASE_URL,DEMO_MODE=1) turns on their own demo, not ours. - Merges to the default branch deploy immediately; anything merged is live. The PR quality gate (
task ci) is the deploy gate. scripts/gains no new runtime dependency; the reset path needs onlypsqland Task on the runner.
Alternatives Considered
Section titled “Alternatives Considered”- Fly cron (scheduled machines) for the reset. Rejected — a second machine config to parameterize and for cloners to pay for; GitHub’s scheduler is free, visible in the Actions tab, and uses the same secret plumbing as the other workflows.
- Reset by deleting anonymous
usersrows (cascade). Rejected — orphans GoTrue auth records; the reaper already owns identity deletion end-to-end. - Full database re-create (drop schema, re-migrate, re-seed). Rejected — destroys upgraded users’ progress and breaks live sessions mid-reset for no additional protection, since shared data is read-only.
References
Section titled “References”- Related: ADR-024 (demo app, guest mode, reaper), ADR-025 (deployment target, release pipeline), ADR-030 (public manifest).
Enforcement
Section titled “Enforcement”- Testable consequences:
- TC-1:
task demo:seedandtask demo:resetrefuse to run unlessDEMO_MODE=1. - TC-2: The nightly reset workflow is double-gated (secret present AND repo var
DEMO_MODE=1).
- TC-1:
- Checks:
- TC-1 → Taskfile
preconditionson both targets (status: block, pre-existing) - TC-2 → gate jobs in
demo-reset.yml/deploy.yml(status: block, pre-existing)
- TC-1 → Taskfile
- Not machine-checkable: The reset-scope guarantee (deletes only anonymous users’ content, never identity or registered accounts) — lives in
sql/demo/reset.sqlsemantics, review territory. - Graduation log: (empty)