Lumen/ARCHITECTURE-DECISIONS.md
Lumen Stage1 fcc18ddcc8
Some checks failed
ci / check (push) Has been cancelled
Checkpoint: current Lumen state
2026-09-23 18:58:21 -05:00

637 lines
42 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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.
- **Persistence correction (2026-08-31):** P1 requires the staging journal to be in each target slot database, alongside `files` and `assets`. `lumen-system` contains active/readback metadata only; it is not a source of per-file staging progress. This preserves one-database atomicity for file/asset + progress without a cross-database transaction.
- **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.
- **Persistence correction (2026-08-31):** The F-1 progress record is the authoritative `staging` journal inside the target slot database. It is committed in the same short transaction as its corresponding file or asset. The system database retains only the active pointer, verification/readback metadata, and boot metadata; no cross-database transaction is used for staging.
- **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 slot-local 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
```