ADR-0039: Online-Only Default Mode — Decoupling the Offline Stack (wayfinder map #667)
Status: accepted Date: 2026-08-18 Deciders: @barateza Tags: [online-only, offline, sync, pwa, dexie, d1, flag, island, lgpd, backend-primary]
Context
Neemias has been local-first with write-through (ADR-0030): reads come from the IndexedDB mirror (DexieAdapter), writes go to the local store + sync_queue and drain to D1. Three confirmed troublemakers keep breaking: login-hydration staleness (#654), encryption-at-rest bugs, and sync-queue retry loops (stuck PENDING/FAILED). The repo's own docs contradict each other — REASONIX.md says "backend-primary, D1 source of truth"; app/CONTEXT.md + ADR-0030 say "offline-first, local source of truth" — the code is really local-first with write-through. Wayfinder map #667 ("Decouple offline stack → online-only default (offline check-in island)") resolved the contradiction toward backend-primary: the admin/attendance/students/reports surface reads and writes straight to the Worker API (/api/v1, REST CRUD, audit trail preserved), while the offline stack is decoupled behind the StorageBackend seam and feature-flagged off — code kept, unsupported, reversible. The one exception is the offline check-in/check-out island (issue #144 flow), which keeps working as a narrow offline capability.
All decisions below were settled by grilling (#669, #670, #672, #673, #674), proven by prototype (#676 read layer; #683 island — browser e2e validated 2026-08-18 on the seeded local stack), and implemented incrementally on feature branches (#680, #682). Rollout is demo-first (app.neemias.app validation builds only), flag default OFF until this ADR + the prototype pass.
Decision
1. Online-only is the default direction; the legacy path stays feature-flagged, unsupported, reversible
The destination is backend-primary: D1 is the source of truth; the app is a thin REST client when the flag is ON. The legacy offline stack is not deleted and not rehabilitated — it stays in the repo, gated off by the flag, byte-for-byte reversible (rebuild with the flag OFF restores the legacy behavior exactly). No in-place fixes of the legacy troublemakers (hydration, encryption, sync retries) — the effort decouples away from them.
2. Read path — typed REST clients + TanStack Query (#669, #677, #680)
- Typed read clients per module on the shared fetch wrapper (
app/src/api/client.ts→apiUrl/authHeaders/handleResponse/getJson;readClients.tswithfetchAllPagesfull-tablenextCursorloops) replaceuseSqlQuerycall sites (33 total) — parity with today's full-table reads and client-side filters, no server pagination. - Async-state layer = TanStack Query (
@tanstack/react-query): the typed clients are thequeryFnlayer; hierarchicalqueryKeys(entity-prefix invalidation); reference-ish tablesstaleTime: Infinity+ boot prefetch; hot data ~60s;retry: false/refetchOnWindowFocus: false→ fail-fast offline error banner + retry CTA; Zod-parse at the client boundary (required — PII validated before entering the cache/UI). - Mutation→read invalidation:
useMutation.onSuccess→invalidateQueries({ queryKey: [entity] });setQueryDatainsert on create (no empty-list flash); optimistic UI per-page (attendance only). No SSE ack, no local-write/sync trigger — the REST response is authoritative. createStore(app/src/storage/index.ts) becomes the flag-driven composition switch; the legacy seam stays untouched and reversible.
3. Write path — per-entity REST CRUD, event-sourcing preserved (#670)
- All writes reuse the existing per-entity REST CRUD routes — they already route through
applyEvent(unifiedeventsrow + projection row + capacity adjustment in one atomic batch; audit = unifiedeventstable per ADR-0023;audit_logsvestigial). Event-sourcing is preserved end-to-end; the client issues typed commands only, no local mirror. Idempotency-Keymandatory per mutation: route-levelidempotentMutationwrapper (ledger replay, 409 on payload mismatch); the frontend sends a UUID per user intent, request-lifetime only.- Execution items carried by this decision: nuclei CRUD routes (
POST/PATCH/DELETE /api/v1/nuclei); optional client-suppliedcreatedAt(so offline replay records the true offline time); quick-register client UUID acceptance (offline-created students).
4. Flag & config — build-time VITE_ONLINE_ONLY (#672)
- Build-time env var only (
VITE_ONLINE_ONLY === "true",app/src/config.ts), matching the repo's 100% build-timeVITE_*convention; no runtime toggle (would ship both modes in every bundle and let a stray localStorage key re-enable the unsupported path). - Default OFF everywhere; rolled out 2026-08-21:
app.neemias.app(demo) and production IJCP (ijcp.neemias.app) now build ON (VITE_ONLINE_ONLY=true), after the demo validation passed and the release path was hardened (#703:BUILD_MODEmarker +scripts/verify-online-build.sh+ pre-push/CI guards — a build without the flag fails loudly). Per-deployment = env-file selection at build. - Under the flag:
createStore()returns aDisabledBackend(fail-fast "online-only" state on any legacy read — never a silent empty mirror); boot skips localseedDatabaseIfNeeded()and encryption init (PII never touches IndexedDB; the demo seed targets the Worker/D1);OfflineProvider/sync engine mount no-op (no drain loops, no queue); login hydration becomes a query-cache seed (#679/#682 — prefetch students+classes+roles,PermissionStore.seedFromRoles, legacy hydrate early-returns); the island is flag-independent (its own DB is the only local store under the flag); PWA behavior per §6.
5. Offline island — narrow check-in/check-out (#671, #683)
The only local state in online-only mode. Covenant (user): narrow and disciplined — no generic sync engine, no encryption-at-rest machinery, wiped on logout.
- Store: dedicated Dexie/IndexedDB
neemias-island(roster, classes, today's sessions, church config, daily secret, append-only replay queue), versioned,navigator.storage.persist(), wiped on logout. Separated from the legacy mirror and the TanStack cache. (Literature-backed: IndexedDB-via-Dexie is the standard for this pattern; SQLite-WASM/OPFS rejected — wrong scale, COOP/COEP header cost, re-imports the reverted stack.) - Daily-secret convergence: server check-in/check-out derive codes via
deriveDailySecret(not the raw mastercheckin_secret) — the client caches the daily secret and computes the identical HMAC code offline, so offline codes match server codes at replay (proven: e2ecodeMatches:1, codeMismatches:0, codeX43GQ2). - Offline check-out is locally-validated (upgrades the old "best-effort with revalidation"): local code validation, attempt counting vs cached
maxCodeAttempts,CHECKOUT_ATTEMPTevents queued,EMERGENCY_CHECKOUThonoring cachedemergencyAdminOnly;AUTHORIZED_LISToffline uses minimal cached match data (student_id, relationship, last-4 phone suffix) — the hardNOT_AUTHORIZEDgate is preserved; if never synced,EMERGENCY_CHECKOUT-only fallback. - Quick visitor registration: the island generates the student UUID client-side; replay accepts client-supplied
student_id+createdAt(execution item in §3). - Replay on reconnect: FIFO through the existing REST routes with per-event UUID
Idempotency-Key+ originalcreatedAt; capacity is re-validated at replay (offline shows a stale-data warning; a full session at replay → 409 surfaces as a CONFLICT in a resolution view — admin force-with-justification or void; offline admissions are never silently dropped). - Offline auth (probed by #683): the island is in-session-offline capable only. There is no persisted session/token in online-only mode; a receptionist logged in before the drop keeps working until token expiry or reload; a cold device with no connectivity cannot reach the island. v1 accepts and warns (login before service; banner flags that session expiry ends the island until connectivity returns). The extension policy (session extension vs device-level unlock) is deferred.
6. PWA / service worker — shell-only, zero data caching (#674)
- Precache the app shell (built JS/CSS/HTML + icons + manifest); runtime-cache fonts only (SWR, versioned cache name); explicit
/api/v1/*→NetworkOnly— never cache API responses. One SW config for both modes. - Stale shell by design: the shell is code, not data — it lets the app boot offline (island works; everything else shows the fail-fast banner). Data is always live from
/api/v1. - Invalidation: content-hashed assets +
cleanupOutdatedCaches+ versioned font cache; update flow =registerType: "prompt"+ "Nova versão disponível" banner (the reception desk controls when a reload happens). - The SW's shell precache is load-bearing for the island — without it the island can't boot offline.
7. Migration on flip (#673)
- Drain-then-flip on the first authenticated boot under the flag: one final
drainQueueofPENDING/RETRYING(reusing the existing drain; token is available post-auth). - Leftovers are surfaced, never silently discarded:
FAILED(>6 retries) + still-pending entries appear on a one-time "Local-only data" screen (counts per entity, Export JSON/CSV via the sync registry, Discard with explicit consent). - Then the legacy
neemias-dbis deleted (LGPD: no stale local copies of personal data; storage hygiene). Consequences: no persisted local session → re-login on next visit (consistent with online-only). The island DB is untouched. - Reversible flip: rebuild with the flag OFF → legacy path boots → fresh hydration from D1 at login (students+roles) recreates the mirror on demand; D1 remains the durable source of truth. Accepted consequence: the legacy mirror starts fresh.
Consequences
- Legacy path is unsupported: the troublemakers (hydration staleness, encryption-at-rest, sync-queue retries) are decoupled away from, never rehabilitated. Code kept, flagged off, reversible.
- The island is the only local state in online-only mode; its offline capability is in-session-only (documented in §5; policy deferred).
- Seed-vs-Zod finding (from #683 e2e): the demo seed's class UUIDs (
00000000-…-0001) are not valid v1-8 UUIDs, so the Zod-bound read clients (studentListItemSchema/classListItemSchema) reject the demo seed — the seed/classes must use valid UUIDs before the demo flipsONLINE_ONLY. - Bundle: the ON build may tree-shake the legacy seam once the read sweep replaces every
useSqlQuerycall site. - Rollout gates: demo-first validation builds; this ADR + the prototype are the gates for the flip; production instances flip only after validation.
References
- Wayfinder map #667 and its decisions: #668 (coverage map), #669 (read path), #670 (write path), #671 (island), #672 (flag), #673 (migration), #674 (PWA), #676 (read prototype), #677 (query layer), #678 (invalidation), #679 (hydration seed), #680/#682 (execution), #683 (island prototype + e2e).
docs/research/rest-coverage-2026.md,docs/research/prototype-676-read-layer-evaluation-2026.md,docs/research/hydration-query-cache-seeding-2026.md,docs/research/prototype-683-offline-island-evaluation-2026.md.- Prior ADRs: ADR-0023 (unified events/audit), ADR-0026 (deterministic daily codes), ADR-0030 (Dexie reversion), ADR-0032 (Durable Objects fan-out), ADR-0036 (authorized pickup list).