Stage 1: project foundation (strict TS, lint boundaries B-1..B-7, directory structure, CI, boundary tests)

- dedicated git repo at /home/avi/Projects/Lumen (main)
- TypeScript strict (target ES2022, bundler, exactOptionalPropertyTypes, noUncheckedIndexedAccess)
- ESLint 9 + typescript-eslint strictTypeChecked + eslint-plugin-boundaries for B-1..B-7, no-restricted-globals/syntax for B-1/B-7
- Prettier 3.5
- Structure per IMPLEMENTATION-CONTRACT.md §4 (src/platform/idb|cache|sw, storage, data, sync/{transport,verifier}, domain/{emergency,schedule,map,festival,readiness,clock,favorites}, ui/{components,views,router,render}, app, emergency-baseline, assets, public, content, pipeline, tests, scripts)
- CI: .github/workflows/ci.yml (typecheck + lint + format + test)
- Boundary tests: tests/unit/boundaries.test.ts (4 tests) + scripts/check-boundaries.ts
- No feature code, no PWA/IDB/sync/mesh/accounts per contract Stage 1
This commit is contained in:
Lumen Stage1 2026-08-30 23:25:35 -05:00
commit c0bfd413ff
100 changed files with 9863 additions and 0 deletions

635
ARCHITECTURE-DECISIONS.md Normal file
View file

@ -0,0 +1,635 @@
# Lumen — Architecture Decision Records (Phase 1)
All decisions below follow the evaluation discipline required for this phase:
problem → alternatives → criteria → evaluation → decision → reasoning →
consequences → risks → reversibility. Decisions were made against Lumen's
actual requirements (300–500 attendees, offline-first PWA, small team), not
technology popularity.
Status values: **Accepted** (adopted now), **Proposed** (adopted pending the
listed validation spike).
---
## ADR-001: Offline-first architecture
- **Status:** Accepted
- **Context:** The festival environment may have no usable connectivity of any
kind (DISCOVERY §3). The product's core promise: everything attendees need
works with zero internet.
- **Problem:** Choose the fundamental data-flow posture: online-first with
offline fallback, offline-first with online enhancement, or offline-only.
- **Decision:** Offline-first. Local storage is the source of truth for all
critical functionality. Network connectivity is an enhancement used only for
updates/optional live content. No critical code path may perform a blocking
network call.
- **Alternatives considered:**
1. *Online-first + graceful degradation* — rejected: violates invariants 1–4;
degradation at a festival is the common case, not the edge case.
2. *Offline-only (never touches network)* — rejected: forfeits schedule
corrections, emergency contact updates, and app update delivery; the
brief explicitly allows optional online features.
- **Evaluation criteria:** invariant compliance (1–10); failure-mode simplicity;
ops burden; honesty of user-facing state.
- **Reasoning:** Offline-first is the only posture in which connectivity loss is
a non-event. It forces the valuable side-effects this product needs anyway:
versioned data packages, verifiable readiness, atomic updates.
- **Consequences:** one-time online bootstrap required (C-17); explicit readiness
state machine needed (ADR-006/§12); all content must be data (invariant 13);
a static publishing pipeline suffices (no backend).
- **Risks:** bootstrap distribution is a product/ops problem (RK-2); users who
never prepare arrive with baseline only.
- **Reversibility:** Not reversible without rebuilding — but no plausible future
requirement makes online-first desirable.
- **Related:** ADR-002, ADR-004, ADR-005, ADR-006, ADR-010.
---
## ADR-002: PWA strategy
- **Status:** Accepted — addendum after SPIKE-07 (validation: ARCHITECTURE-VALIDATION.md §2)
- **Context:** Lumen must not be a native app (brief). Target browsers: iOS
Safari, Android Chrome. DISCOVERY §12 established install-first as
load-bearing (storage persistence, future push, standalone UX on iOS).
- **Problem:** Define the delivery/installation model and how much to lean on
PWA-specific behaviors.
- **Decision:** Installed-first PWA on a single HTTPS origin. Web app manifest
with standalone display; install coaching is a first-class UX flow (manual
steps on iOS; native prompt on Android where available). The app must remain
fully functional when *not* installed, while honestly warning that storage
may not persist. No native wrappers (no TWA/App Store packaging).
- **Addendum (SPIKE-07):** Preparation has explicit levels L0 (shell + floor → NOT_READY/BASELINE_ONLY), L1 Minimum Safe (L0 + emergency + schedule → PARTIAL), L2 Full (L1 + map + info + assets → READY) (`SPIKE-07-BOOTSTRAP.md:31`). Fixed download order `emergency → schedule → info → map-base → assets`; L1 already yields core festival survival. Prep before install is allowed on iOS tab (same origin, survives until eviction) — coach offers "install first (recommended)" and "get data now" (`SPIKE-07-BOOTSTRAP.md:58`). Maps to readiness predicate C1–C8 (`SPIKE-05-OFFLINE-READY.md:28`). No direction change; install-first remains load-bearing but never blocks tab use.
- **Alternatives considered:**
1. *Tab-only web app, no install push* — rejected: iOS 7-day eviction of
non-installed storage makes the offline promise unreliable (DISCOVERY PW-1).
2. *Native wrapper (TWA/wrapped webview)* — rejected: store dependency
violates brief; adds ops without adding capability we need in V1.
3. *Native app* — out of scope by definition.
- **Evaluation criteria:** storage persistence prospects; install friction; ops
surface; compliance with "no app stores" constraint.
- **Reasoning:** Installation is the single highest-leverage action available on
the web platform for this product: it improves storage persistence, unlocks
standalone UX, and is the precondition for any future push. The architecture
therefore spends UX effort on install coaching deliberately.
- **Consequences:** install coach UX required; capability detection must
distinguish installed vs tab mode; all features must still work in tab mode
(degraded-persistence warning only).
- **Risks:** users refuse/forget to install (RK-1); iOS EU region variability.
- **Reversibility:** Install coaching can be relaxed later; the single-origin
model is hard to reverse (storage is origin-keyed) — accepted.
- **Related:** ADR-001, ADR-004, ADR-014.
---
## ADR-003: Frontend technology
- **Status:** Accepted (with explicit reconsider trigger)
- **Context:** Small/solo team (DISCOVERY A-17); low-end Android in audience
(A-13); app surface is modest: 4 destinations, lists, details, map, status.
DISCOVERY AD-1 left this open.
- **Problem:** Choose the UI technology stack.
- **Decision:** **Vanilla TypeScript (strict), no UI framework.** A small
internal observable-store module, a small history-API router, and DOM
rendering via structured, escaping render functions. Build with a
single-bundle bundler producing hashed immutable assets.
- **Alternatives considered:**
| Alternative | Runtime size | Churn/longevity risk | Complexity for small team | Low-end perf |
|---|---|---|---|---|
| Vanilla TS (chosen) | ~0 beyond own code | None (no deps) | Requires discipline (no framework guardrails) | Best |
| Preact | ~4 KB | Low (stable, mature) | Familiar mental model | Very good |
| Svelte | Small output | Medium (compiler-coupling; major-version churn) | Good DX | Very good |
| React | ~40 KB+ runtime | Medium | Very familiar talent pool | Good, not best |
| Vue/Angular/Solid | Medium+ | Low-Medium | Good DX | Good |
- **Evaluation criteria:** bundle/JS budget on low-end devices; dependency churn
over multi-year festival lifetime; operational risk for a small team;
accessibility control; framework-neutrality of offline architecture.
- **Reasoning:** The app's UI complexity is low and its lifetime is long
(reused across festival editions). Every framework would work; none solves a
problem Lumen actually has, while each adds bundle weight and version-churn
risk. "Simplest architecture that satisfies requirements" (principle 15)
selects vanilla TS. This is explicitly *not* chosen for ideological reasons;
the reconsider trigger below is part of the decision.
- **Consequences:** hand-rolled store/router (small, tested); stricter code
review on UI correctness; zero framework upgrade burden; smallest possible JS.
- **Risks:** hand-rolled UI bugs (RK-7); onboarding friction if contributors
expect a framework.
- **Reversibility / reconsider trigger:** If during implementation the view
layer shows sustained complexity beyond simple store→render (e.g., many
interdependent live views), migrate to **Preact** (closest mental model,
smallest migration cost). The layering in ARCHITECTURE-DESIGN §6 keeps
domain/data code untouched by such a migration.
- **Related:** ADR-002, ADR-008.
---
## ADR-004: Local storage
- **Status:** Accepted (YELLOW — device-conditional) — validated at design-logic level by SPIKE-01 + SPIKE-02; production readiness conditional on physical-device tests per SPIKE-01 §5 (see ARCHITECTURE-VALIDATION.md §2, §4, §7)
- **Context (validation update):** SPIKE-01 24-item findings confirm IDB is correct for this workload (spec CONFIRMED for transactions/atomicity/multiple DBs); SPIKE-02 16/16 PASS proves A/B ordering makes single-DB atomicity sufficient. Remaining INFERRED/UNVERIFIED items (iOS jetsam atomicity, quota-near-full writes, Blob read-back on low-end) are isolated and queued for device matrix — they do not contradict the decision.
- **Context:** DISCOVERY AD-3/TQ-2/TQ-3. Everything offline depends on local
storage that browsers may evict.
- **Problem:** Choose storage technologies and layout for shell, datasets, and
user state.
- **Decision:**
- **IndexedDB** for datasets (A/B slot databases) and user state, accessed
through a thin internal promise wrapper (no external DB library).
- **Cache Storage** for the app shell only.
- **localStorage** only for trivial, non-load-bearing flags (guarded).
- Assets stored as Blobs inside the dataset slot DB (one failure domain for
rollback).
- **Alternatives considered:**
1. *Dexie/idb libraries* — rejected for V1: access patterns are few and
known; an internal wrapper (~small module) avoids a dependency and keeps
IDB transaction discipline audited in one place. Reconsider if wrapper
proves error-prone.
2. *SQLite-in-WASM (wa-sqlite/cr-sqlite)* — rejected: dataset scale (≤1k
events, ≤50 MB) doesn't need SQL; WASM memory limits on iOS and added
complexity fail the simplicity criterion. Reconsider only if query needs
explode (DD-10).
3. *OPFS files* — rejected as primary: workable but weaker query/transaction
semantics; no advantage at this scale.
4. *localStorage-only* — rejected: size limits (5–10 MB) and synchronous API.
- **Evaluation criteria:** durability semantics; transactional atomicity
(needed by ADR-006); Blob support; iOS reliability evidence; dependency
surface; quota behavior.
- **Reasoning:** IDB is the only browser store that offers transactions +
structured data + Blobs + generous quotas on both targets. The thin-wrapper
choice follows principle 15. SPIKE-01 validated the design at spec/policy level; the sole
remaining platform assumption (IDB atomicity under iOS jetsam kill) is isolated to one UNVERIFIED item queued for device testing in SPIKE-01 §5 — it does not block the decision.
- **Consequences:** A/B slots are separate IDB databases + a system DB holding
the active pointer; rollback/activation is one transaction on the system DB;
eviction detection via boot verification (no eviction event exists on the
platform).
- **Validation addendum (SPIKE-01 P1–P8, SPIKE-02 F-1…F-3 — normative):** P1 one short txn per staged file (bytes+progress together); P2 activation single txn on `lumen-system`; P3 wrapped IDB + QuotaExceededError keeps active; P4 free-space pre-check 2× package via `storage.estimate()`; P5 single record ≤6 MB; P6 `persist()` requested but never relied upon; P7 boot light verification as eviction detection; P8 no dataset state in SW. SPIKE-02 adds: F-1 bytes+progress atomic, F-2 verification record in activation txn, F-3 `readbackPending` flag, F-4 rollback depth 1 accepted.
- **Risks:** iOS IDB edge bugs (mitigation: wrapper isolation, spikes, re-prep
recovery); silent eviction (mitigation: RECOVERY state); residual device risk is YELLOW until D1–D4 matrix passes (ARCHITECTURE-VALIDATION.md §4).
- **Reversibility:** Storage layout is internal to the data layer; migrating to
another store later touches DatasetStore/persistence modules only, not
features. Medium reversibility.
- **Related:** ADR-001, ADR-005, ADR-006.
---
## ADR-005: Festival Data Package
- **Status:** Accepted — addendum after SPIKE-04/05/06/08 (ARCHITECTURE-VALIDATION.md §2). Schema now normative per SPIKE-04 §2 fragments; `required` flag, no-expiration rule, emergency sub-versioning and user-data schema split are part of the decision.
- **Validation addendum:** F-1 `sections[*].required` (default true; readiness gates on required only, SPIKE-04/05); F-2 no expiration for datasets/sections — offline device must never see data "expire" (SPIKE-04:50); F-3 emergency independent `emergencySchemaVersion`/`contentVersion`/`updatedAt` for floor/dataset compatibility (SPIKE-04:70, SPIKE-06:110); F-4 user-data `schemaVersion` separate from package schema with idempotent boot migrations. Budgets and pipeline gates (5 gates, SPIKE-04:189) enforce per-file ≤6 MB, image caps from SPIKE-03, total ≤40 MB. JSON+hash+Ed25519 remains sufficient at this scale (F-5).
- **Previous status:** Accepted
- **Context:** DISCOVERY §15, AD-4/AD-10. Content must be versioned data
(invariant 13); organizers need an authoring path (AMB-2).
- **Problem:** Define the package format, structure, authoring source, and
publication model.
- **Decision:** Festival Data Package v1 = a directory of **JSON section
documents** (`emergency`, `schedule`, `map`, `info`, `assets` inventory) +
binary assets + `manifest.json` (inventory, SHA-256 hashes, sizes, edition,
monotonic packageVersion, schemaVersion, compatibility envelope, festival
metadata) + `signature.json` (Ed25519 over the manifest digest).
Authoritative source: a **content repository** (organizer-edited sheets and
blocks) transformed by an automated validate→build→hash→sign→upload pipeline.
Distribution: immutable versioned URLs on the static CDN + mutable
`latest.json` pointer.
- **Alternatives considered:**
1. *MessagePack/CBOR binary sections* — rejected: ~30–50% size win on ≤3 MB
of JSON is not worth losing human-auditability and simpler tooling.
2. *SQLite file as the package* — rejected: couples data format to a storage
engine choice; integrity-per-file becomes coarser; rejected engine
(ADR-004).
3. *Single monolithic JSON file* — rejected: prevents per-section partial
readiness (PARTIAL states), prioritized download order, and section-level
quarantine.
4. *Dynamic CMS/API backend* — rejected: adds server state, auth, and ops
for zero V1 benefit (A-17).
- **Evaluation criteria:** authorability; verifiability/integrity granularity;
partial-readiness support; size; tooling simplicity; longevity.
- **Reasoning:** JSON sections give section-level hashes, prioritized
downloads, and human-inspectable content (emergency content must be
auditable). The manifest centralizes completeness so the app never guesses
(invariant 8/10 of discovery R-D3). Static publishing matches the ops
reality of a small team.
- **Consequences:** pipeline tooling must exist (schema validators, budgets,
signer); stable event IDs become contractual; content review happens in the
repo (audit trail).
- **Risks:** organizer content quality/variance (mitigation: intake templates +
pipeline validation gates — see OQ-2/OQ-6); JSON verbosity if content grows
(budgets guard).
- **Reversibility:** Format is versioned (`lumen.package/1`); a v2 format can
coexist via schemaVersion envelope. High reversibility by design.
- **Related:** ADR-004, ADR-006, ADR-013, ADR-014.
---
## ADR-006: Atomic updates and rollback
- **Status:** Accepted (YELLOW — device-conditional) — state machine proven 16/16 by SPIKE-02; production readiness conditional on IDB atomicity under real kills on iOS (SPIKE-01 §5, SPIKE-02 §6) — see ARCHITECTURE-VALIDATION.md §2, §4, §7
- **Validation addendum (SPIKE-02 F-1…F-4 — normative):** F-1 bytes+progress in same per-file txn; F-2 verification record (what/when/version) in activation txn; F-3 `readbackPending` flag set in activation and cleared after readback; F-4 rollback depth = 1 (new staging wipes inactive slot — accepted trade-off, 3-slot rejected for footprint). Ordering proof: single-DB atomicity suffices because inactive slot is fully written+validated before pointer moves; quarantine + monotonic versions prevent replay of bad packages.
- **Context:** Invariants 5–8; DISCOVERY AD-6; iOS SW-kill reality (PW-6).
- **Problem:** Apply dataset updates such that the observable state is always
"old valid dataset" or "new valid dataset", surviving interruption at any
stage; provide rollback.
- **Decision:** **A/B dual-slot model.** Two dataset slots (IDB databases).
Updates stage exclusively into the *inactive* slot with per-file download,
SHA-256 verification, and persisted progress (resumable). After full
verification, activation is a **single transaction** on the `lumen-system`
DB flipping the active pointer + recording version/verification metadata.
Post-activation readback spot-check; failure triggers automatic rollback to
the previous slot. Previous slot retained for user-initiated restore until
next staging or GC policy. Organizer-side rollback = republish old content at
a new higher version (runbook). Downloads run in the **page context**
(C-21), never the SW.
- **Alternatives considered:**
1. *In-place overwrite with journaling* — rejected: far more failure modes;
IDB gives cheap whole-database slots, so journaling complexity is
unnecessary.
2. *Version-keyed stores with GC (no fixed slots)* — rejected: unbounded
store proliferation complicates quota reasoning; A/B bounds storage at
2× dataset.
3. *SW-driven download + Cache API staging* — rejected: iOS terminates SWs
aggressively; page-context downloads survive SW death and can show
progress/resume UX.
- **Evaluation criteria:** invariant compliance under kill-at-any-point;
storage overhead; resumability; implementation simplicity; rollback support.
- **Reasoning:** The pointer-flip transaction is the smallest possible atomic
boundary; keeping all writes on the inactive slot makes the invariant
trivially arguable. All nine failure stages map to "old dataset untouched"
or "rollback to old" (ARCHITECTURE-DESIGN §18.2).
- **Consequences:** 2× worst-case dataset storage (bounded by budgets ≤40 MB →
≤80 MB worst case; GC trims); staging progress schema needed; activation
metadata is the single source of readiness truth.
- **Risks:** quota pressure during staging with both slots full (mitigation:
GC trigger before staging; pre-check free space); pointer-flip transaction
failure on pathological IDB state (mitigation: retry once → keep old →
diagnostics).
- **Reversibility:** The slot mechanism is internal to data/sync layers; can be
replaced without touching features. Medium reversibility.
- **Related:** ADR-004, ADR-005, ADR-010, ADR-013.
---
## ADR-007: Emergency baseline
- **Status:** Accepted — addendum after SPIKE-06 (ARCHITECTURE-VALIDATION.md §2). Fixed tier contents, merge rules, and zero-IDB forward-tolerant floor path are now normative.
- **Validation addendum (SPIKE-06):** Tier-1 floor JSON shape fixed (`SPIKE-06:12` life-safety minimum, ≤16 KB), tier-2 full detail, tier-3 reserved signed ephemeral notices. Resolution `render_emergency()` prefers highest compatible tier with defensive merge (`SPIKE-06:60`); F-1 hardening: floor renderer forward-tolerant and reachable with zero IDB access for BASELINE_ONLY/eviction (`SPIKE-06:75`). Same-source generation of floor + dataset section prevents drift (`ARCHITECTURE-DESIGN.md:345`).
- **Context:** Invariant 2; DISCOVERY OF-3/EM-2; emergency must survive even
total storage loss.
- **Problem:** Decide whether and how to embed emergency information in the
app shell in addition to the dataset.
- **Decision:** Three tiers. **Tier 1 — embedded baseline:** a minimal emergency
record (emergency number + dial action, festival address, coordinates,
security contact, first-aid/AED summaries, muster points, exit summaries,
core procedures) **compiled into the app shell at build time**, generated
from the *same emergency source sheet* that produces the dataset section
(no drift). Immutable per shell build; ≤16 KB; frozen schema. **Tier 2 —
dataset emergency section:** full detail, signed, updateable. **Tier 3 —
optional live notices:** reserved seam, not V1. Rendering prefers the
highest verified tier; provenance always labeled.
- **Alternatives considered:**
1. *Dataset-only emergency* — rejected: fails invariant 2 under eviction /
failed update / incompatible dataset.
2. *Baseline hand-maintained separately from dataset content* — rejected:
drift risk between baseline and dataset is a safety issue; single-source
generation removes it.
3. *Baseline in localStorage instead of compiled-in* — rejected: localStorage
dies with storage eviction too; only shell bytes survive everything.
- **Evaluation criteria:** survival under every failure mode; content drift
risk; update latency tolerance; size cost.
- **Reasoning:** Emergency is the one feature where "degraded but present" is
unacceptable; embedding in immutable shell bytes is the only mechanism that
survives total storage loss. Generating it from the same source eliminates
the classic two-copies drift hazard.
- **Consequences:** baseline updates require a shell release (slower cadence —
accepted, and labeled); shell build pipeline gains a baseline-generation
step; emergency rendering has a no-IDB path.
- **Risks:** baseline content frozen at build time while contacts change
(mitigation: Tier 2 updates + provenance labels + organizer physical
channels); baseline scope creep (mitigation: hard 16 KB cap and content
policy).
- **Reversibility:** Additive; tiers can be extended without removing the floor.
- **Related:** ADR-001, ADR-005, ADR-013, ADR-014.
---
## ADR-008: Map architecture
- **Status:** Accepted (YELLOW — device-conditional) — validated by SPIKE-03; production readiness conditional on device protocol in SPIKE-03 §7 (see ARCHITECTURE-VALIDATION.md §2, §4, §7)
- **Validation addendum (SPIKE-03 F-1,F-3,F-4 — normative):** F-1 replaces 4096 cap: overview ≤1600 px longest edge, detail ≤3072 px, total decoded ≤~35 MB, at most one detail resident (OER; swaps overview out) (`SPIKE-03:58`); F-3 lazy object-URL lifecycle (create per level, revoke on switch); F-4 dark-mode `brightness(.72) saturate(.85)` + optional night asset hook `map-base/overview-night` (`SPIKE-03:88`); WebP 28 MB budget stands with margin. Flip conditions + fallbacks (overview-only, 2×2 tile-split) predefined.
- **Previous pending:** real-art bake-off and real map art (OQ-6) — now tracked as device/content validation, not a Proposed decision.
- **Context:** DISCOVERY AD-7, AMB-3; no online tiles allowed; GPS never
required; low-end devices; organizers most naturally produce illustrated
raster art.
- **Problem:** Choose the offline map representation and interaction model.
- **Decision:** **Raster WebP base map (≤2 zoom levels: overview + optional
detail) + data-driven DOM POI overlay + CSS-transform pan/zoom + first-class
Facilities list view.** POIs carry normalized coordinates in map.json;
markers are real DOM buttons; detail level ≤4096px per side, lazy-decoded;
no geolocation in V1 (schema hook preserved).
- **Alternatives considered:**
1. *Single vector SVG map* — crisp and small for schematic maps, but:
organizer art is typically illustrated raster; complex SVG pan/zoom
perf on low-end devices is unpredictable; large-SVG accessibility is
awkward. Would be reconsidered if organizers supply vector art and
SPIKE-3 shows SVG perf is fine (DD-9 adjacent).
2. *Canvas rendering* — good perf, but: manual hit-testing, poor
accessibility (needs parallel text tree anyway → the list view already
provides it), more code.
3. *Pre-cached raster tile pyramid* — rejected: tile bookkeeping complexity
without need; venue-scale map fits 2 full-image levels under budget.
4. *Online tiles with offline cache* — rejected by constraint (no core
dependence on online tiles).
- **Evaluation criteria:** offline completeness; low-end perf; accessibility;
file size; authoring workflow; interactive markers; search integration;
maintainability.
- **Reasoning:** GPU-scaled image transforms give consistent pan/zoom
performance independent of POI count; DOM markers give free accessibility
and tap targets; the Facilities list doubles as the screen-reader path and
the GPS-free "find nearest facility" path; raster matches what organizers
actually produce.
- **Consequences:** image budgets (≤28 MB map art) enforced in pipeline;
coordinate authoring step in pipeline (tap-tool or measured coords);
detail-level switching logic; no blue dot in V1.
- **Risks:** art exceeds budget (mitigation: split/drop detail level);
transform jank on very old devices (mitigation: SPIKE-3 on low-end device;
fallback = overview-only mode).
- **Reversibility:** Map rendering is isolated in MapService; swapping
representation later doesn't touch other features. High reversibility.
- **Related:** ADR-003, ADR-005, ADR-009.
---
## ADR-009: Time model
- **Status:** Accepted — addendum after SPIKE-08 (ARCHITECTURE-VALIDATION.md §2). Change F-3 normative; clarification F-4 normative.
- **Validation addendum (SPIKE-08 24/24 PASS, V8/ICU):** T1–T7 all PASS; UTC + explicit-zone `Intl` correct offline; precomputed `dayKey` eliminates client day math; DST and 30-min zones handled. **F-3 (change):** sanity-window warning suppressed until `now ≥ festivalStart − 45d` or a skew has been captured — prevents noisy warning for early preparation (`SPIKE-08-TIME-MODEL.md:97`). **F-4 (clarification):** only `Intl.DateTimeFormat` with explicit `timeZone` is allowed; no wall-clock string storage, no manual offset arithmetic. JSC parity on iOS and CDN `Date` observation remain UNVERIFIED queued for device matrix (§4, §7).
- **Previous status:** Accepted
- **Context:** DISCOVERY AD-8, R-S5; Now/Next must work offline; device clocks
may be wrong.
- **Problem:** Define storage, rendering, and correctness strategy for time,
including wrong clocks and DST.
- **Decision:** Events stored as **UTC epoch ms**. Manifest carries the
festival **IANA timezone** and festival window. Rendering via
`Intl.DateTimeFormat` in festival zone (user toggle: device zone). Day
boundaries are **precomputed at publish time** (`dayKey`). A ClockService
computes `now()`: persisted server-time offset (captured from HTTP `Date`
header during any sync) corrects device time; a monotonic anchor detects
mid-session clock changes; a sanity window (festival window ±45 days)
triggers "check your clock" warnings. Never-online devices use device clock
with the sanity heuristic only.
- **Alternatives considered:**
1. *Store/render local festival wall-clock strings* — rejected: ambiguous
across device zones, breaks instant arithmetic for Now/Next and overlaps.
2. *Require network time (NTP-like endpoint)* — rejected: violates offline
invariants.
3. *Trust device clock unconditionally* — rejected as sole strategy: wrong
clocks silently break Now/Next; the offset capture is nearly free.
4. *Continuous background clock sync* — rejected (C-19).
- **Evaluation criteria:** offline correctness; wrong-clock resilience; DST
safety; implementation simplicity.
- **Reasoning:** UTC storage + IANA rendering is the standard correct answer;
the offset mechanism adds real wrong-clock resilience at trivial cost;
precomputed `dayKey` removes client-side day-boundary ambiguity entirely.
- **Consequences:** sync responses must expose a server timestamp (HTTP Date is
sufficient); clock warnings are part of UX; residual risk for never-online
wrong clocks is declared and accepted (absolute times remain readable).
- **Risks:** organizer supplies wrong IANA zone (mitigation: pipeline
validation + fixture tests for DST edges); offset poisoned by a wrong server
clock (mitigation: sanity window; offset only shifts display logic, never
stored event data).
- **Reversibility:** Storage format (UTC ms) is stable regardless of rendering
policy changes. High reversibility for rendering policy.
- **Related:** ADR-001, ADR-005, ADR-010.
---
## ADR-010: Synchronization
- **Status:** Accepted
- **Context:** DISCOVERY A-07/A-12, SY-* questions; no accounts; connectivity
is enhancement-only.
- **Problem:** Define if/how the app talks to servers.
- **Decision:** **Pull-only synchronization**: on app open, on `online` hint,
and on manual request. The only sync operations: fetch `latest.json`, fetch
candidate package, stage/verify/activate (ADR-006). Versions are monotonic;
clients never accept a network downgrade. No uploads, no user-state sync, no
background sync registration, no timers, no push in V1. Announcements schema
reserved but unimplemented (DD-1).
- **Alternatives considered:**
1. *Periodic background sync* — rejected: unsupported on iOS (C-19) and
unnecessary since sync-on-open covers all needs.
2. *Push-based updates* — rejected for V1: iOS push requires install +
region/permission fragility; adds server infra; the pull model already
guarantees eventual consistency whenever the user opens the app.
3. *WebSocket/long-poll live channel* — rejected: server state + connection
management for no V1 requirement.
- **Evaluation criteria:** platform support on both targets; server ops burden;
privacy; invariant 10 compliance.
- **Reasoning:** Pull-only on static files achieves every V1 need with zero
server state and zero permissions; everything more exotic fails the
simplicity criterion.
- **Consequences:** update latency = time-until-next-open-online (accepted and
documented); organizers needing urgency use physical channels (runbook).
- **Risks:** critical schedule change reaches users late (mitigation:
changed-event markers, dataset timestamps, organizer comms redundancy).
- **Reversibility:** Additive — push/announcements can be layered on the same
transport seam later without changing pull. High reversibility.
- **Related:** ADR-005, ADR-006, ADR-011, ADR-014.
---
## ADR-011: Networking abstraction
- **Status:** Accepted
- **Context:** Invariant 11–12; DISCOVERY §17; brief requires a clean future
transport seam without V1 mesh contamination.
- **Problem:** Decide whether a transport abstraction belongs in V1 at all, and
if so its shape.
- **Decision:** A **minimal, byte-oriented Transport interface** under the Sync
Service: `isAvailable()`, `fetchPointer(edition)`, `fetchBytes(path)`. V1
ships exactly one implementation (HTTP over the CDN). The Verifier — not any
transport — decides authenticity. Features/UI never import sync or transport
modules (boundary B-3). No registry, no plugin system, no transport
negotiation: the seam is the interface plus the layering rule.
- **Alternatives considered:**
1. *No abstraction until a second transport exists (YAGNI-pure)* —
reasonable, but the interface costs ~one small module and prevents the
much costlier retrofit of untangling fetch calls from feature code;
accepted only in this minimal form.
2. *Message/sync-semantics abstraction (CRDT op log, gossip protocol
interfaces)* — rejected for V1: speculative; encodes mesh assumptions
(violates principle 14); a byte transport + signed payloads covers every
conceivable future transport, since packages are immutable signed objects.
3. *Adapter over fetch scattered in services* — rejected: that is the leak
invariant 12 forbids.
- **Evaluation criteria:** cost in V1; leak-proofness; sufficiency for plausible
future transports (LAN mirror, native-companion bridge, mesh gateway).
- **Reasoning:** Any future delivery mechanism — including mesh gateways and
native companions — can present "fetch these bytes"; verification stays
client-side. That makes the byte interface the least-speculative seam that
satisfies the requirement.
- **Consequences:** SyncService is transport-parameterized; tests can inject a
fault transport (fault injection for §18.2 stages); UI shows no transport
identity.
- **Risks:** under/over-specification discovered when a real second transport
appears (mitigation: interface is deliberately narrow; versioning it is
cheap).
- **Reversibility:** High — interface is internal.
- **Related:** ADR-006, ADR-010, ADR-012, ADR-013.
---
## ADR-012: Future mesh strategy
- **Status:** Accepted (strategy only; nothing implemented)
- **Context:** Brief: mesh is future-only; DISCOVERY §17 concluded browsers
alone almost certainly cannot host a meaningful mesh (no BLE peripheral, no
ad-hoc Wi-Fi, no background sockets; iOS lacks Web Bluetooth).
- **Problem:** Define how future mesh could attach without contaminating V1,
and what V1 must do now to keep that possible.
- **Decision:** V1 does exactly three things for future mesh: (1) the Transport
seam of ADR-011; (2) the **signed-payload rule** — bytes from any transport
are inert until the Verifier passes them, so untrusted peers can never
inject content; (3) package/announcement formats are immutable,
content-addressed, version-monotonic — properties any store-and-forward or
gossip transport needs. All mesh concerns (peer discovery, routing, dedupe,
replay beyond version monotonicity, peer auth, message size adaptation,
emergency broadcast semantics) are explicitly **future work**. A future mesh
is assumed to require a native companion, hardware relay, or organizer LAN
service; if that proves impossible, V1 loses nothing (HTTP is complete
functionality, not a placeholder).
- **Alternatives considered:**
1. *Design a mesh message schema now* — rejected: encodes assumptions about
a technology not chosen, violating principle 14.
2. *WebRTC data-channel groundwork in V1* — rejected: signaling needs
connectivity anyway; iOS constraints; zero V1 value; pure complexity.
3. *Do nothing at all (no seam)* — rejected: the brief requires an extension
point; the seam chosen costs one interface.
- **Evaluation criteria:** V1 cost; leak-proofness; plausibility under multiple
future mesh shapes (BLE mesh via companion, LAN mirror, WebRTC via gateway).
- **Reasoning:** The cheapest correct move is to make Lumen's data plane
transport-agnostic and authenticity-strict, then stop. Every concrete mesh
capability browsers lack lives outside the app anyway.
- **Consequences:** documentation-only artifacts in V1; future mesh work starts
with a transport implementation + a feasibility study of the out-of-browser
component.
- **Risks:** the seam proves slightly wrong for a real mesh (mitigation:
narrow interface, cheap to version); false expectation that mesh is "easy
later" (mitigation: this ADR documents the out-of-browser likelihood).
- **Reversibility:** Fully — nothing is built.
- **Related:** ADR-011, ADR-013.
---
## ADR-013: Security model
- **Status:** Accepted as a model — crypto **library choice remains Proposed (YELLOW) pending audit** (ARCHITECTURE-VALIDATION.md §2, §7); overall threat model GREEN. Verifier choice (WebCrypto SHA-256 + bundled pure-JS Ed25519) validated as correct per TQ-9/SQ-1 but supply-chain audit is still required (AQ-06).
- **Validation note:** SPIKE-04/02 ordering (signature before hash), per-file SHA-256, monotonic anti-replay, CSP `self`-only, and data-minimization posture all reconciled; no spike contradicts the model. Key custody/rotation drill (AQ-21, S-01) remains ops gate.
- **Context:** DISCOVERY §18; invariants on data integrity; emergency content
is safety-relevant; data minimization mandated.
- **Problem:** Define integrity, authenticity, transport security, content
safety, and privacy posture without unnecessary complexity (no accounts).
- **Decision:**
1. **Package signing:** Ed25519 signature over SHA-256 of the exact manifest
bytes; per-file SHA-256+size in manifest; client embeds a fingerprinted
**public key set** (rotation-capable). Verification implemented with a
small, audited, pure-JS Ed25519 verifier bundled in the shell (WebCrypto
Ed25519 is not guaranteed on the iOS 16.4 baseline) — library choice
Proposed pending audit check.
2. **Transport:** HTTPS-only + HSTS; single origin; immutable package URLs.
3. **Downgrade/replay protection:** monotonic packageVersion acceptance;
organizer rollback only via new version.
4. **Content safety:** structured-data rendering only (no raw HTML); CSP
without inline script/eval; no third-party resources.
5. **Privacy:** no accounts, no telemetry, no location capture; diagnostics
local, scrubbed, user-initiated.
6. **Ops:** signing key offline; singular publisher role; repo audit trail;
key-loss runbook (ship new key via shell update).
- **Alternatives considered:**
1. *TLS-only trust (no signatures)* — rejected: doesn't protect against
hosting/CDN compromise or publish-pipeline mistakes; emergency content
deserves defense in depth.
2. *WebCrypto-only Ed25519* — rejected: baseline iOS gap (DISCOVERY TQ-9);
the bundled verifier removes the gap deterministically.
3. *Full PKI/certificate-style scheme, JWT-based updates, or authenticated
user sessions* — rejected: complexity without a V1 threat that requires
it (principle: realistic over theoretical).
- **Evaluation criteria:** coverage of realistic threats (TH-1…TH-6); key
management practicality for a small team; client cost; privacy posture.
- **Reasoning:** Signature + per-file hashes + monotonic versions defeat the
realistic high-impact threats (tampered/malicious packages, emergency
spoofing, replay) with minimal moving parts; data minimization removes whole
threat classes rather than defending them.
- **Consequences:** signing tooling + key custody in ops; every dataset byte
verified before activation (cost: seconds at staging time); CSP strictness
shapes build output (hashed scripts only).
- **Risks:** signing key loss (runbook + key set rotation); verifier library
supply-chain risk (mitigation: pinned, audited, tiny; TH-11); strict CSP
friction during build (accepted).
- **Reversibility:** Signature scheme can be strengthened (key set mechanism);
removing security would be a regression, not a reversal.
- **Related:** ADR-005, ADR-006, ADR-010, ADR-011, ADR-012.
---
## ADR-014: Deployment strategy
- **Status:** Accepted on topology — **provider choice remains Proposed (YELLOW) pending selection** (AQ-14) (ARCHITECTURE-VALIDATION.md §2, §7)
- **Validation note:** Static CDN, single origin, immutable versioned URLs + `latest.json` pointer + staging/production origins + cache headers per ARCH §24 are all validated; provider is the only unresolved piece and is hard to reverse post-users (origin-keyed storage) so provisional staging origin is correct for development.
- **Previous:** Accepted (provider Proposed pending hosting choice — AQ-14)
- **Context:** DISCOVERY AD-9/AD-10/AD-14; A-17 (small team); HTTPS required;
all content is static signed files.
- **Problem:** Choose hosting topology, release topology, and publishing flow.
- **Decision:** **Static-only deployment on an HTTPS CDN, single origin.**
Layout: `/` (index, `no-cache`), `/assets/<hash>` (immutable),
`/latest.json` (short-lived), `/editions/<ed>/packages/<v>/**` (immutable).
Publishing: content repo → automated pipeline (validate → build sections →
hash → sign → upload → pointer update → smoke check). Two environments:
staging origin and production origin, same code, different origins. App
shell builds are separate releases from dataset publishes (independent
cadence, compatibility envelope per ADR-005/§18.6). No dynamic backend.
- **Alternatives considered:**
1. *Small dynamic server (for latest.json logic, telemetry, push)* —
rejected: no V1 feature needs server logic; ops burden fails A-17.
2. *Self-hosted single server* — possible but: CDN gives free resilience +
edge caching for pre-festival load spike; self-hosting concentrates
outage risk (RK-10). Reconsider only if cost/access constraints demand.
3. *Object storage without CDN front* — equivalent in simplicity; CDN edge
preferred for the pre-festival install spike; provider choice left open.
- **Evaluation criteria:** ops burden; resilience during festival; cost;
publish safety (staging → prod); HTTPS/headers control.
- **Reasoning:** Everything the system serves is immutable bytes or one tiny
mutable pointer; static hosting is the exact fit and keeps the failure
surface to "files in or out".
- **Consequences:** publisher credentials live at pipeline/CDN layer; publish
runbooks required; cache headers are part of the contract; staging edition
for tests.
- **Risks:** CDN outage blocks updates during festival (impact low — devices
hold last-good data; RK-10); provider lock-in minimal (plain files).
- **Reversibility:** Provider swap is a DNS/origin change with storage
consequences for existing users (origin-keyed storage!) — an origin change
strands installed data; therefore pick the production origin deliberately
and early (AQ-18 blocks production setup, not development).
- **Related:** ADR-002, ADR-005, ADR-013.
---
## Decision dependency map
```mermaid
flowchart TD
A1[ADR-001 Offline-first] --> A2[ADR-002 PWA strategy]
A1 --> A4[ADR-004 Local storage]
A1 --> A5[ADR-005 Festival Data Package]
A1 --> A10[ADR-010 Sync pull-only]
A2 --> A14[ADR-014 Deployment]
A4 --> A6[ADR-006 Atomic updates]
A5 --> A6
A5 --> A7[ADR-007 Emergency baseline]
A5 --> A9[ADR-009 Time model]
A5 --> A8[ADR-008 Map]
A5 --> A13[ADR-013 Security]
A6 --> A11[ADR-011 Transport seam]
A10 --> A11
A11 --> A12[ADR-012 Mesh strategy]
A13 --> A12
A3[ADR-003 Vanilla TS] --> A8
```