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

950
ARCHITECTURE-DESIGN.md Normal file
View file

@ -0,0 +1,950 @@
# Lumen — Architecture Design (Phase 1)
- **Phase:** Architecture Phase 1 — decisions and design only. No implementation, no code, no prototypes.
- **Date:** 2026-08-30
- **Source of truth:** `/home/avi/Projects/Lumen/DISCOVERY.md`
- **Companion documents:** `ARCHITECTURE-DECISIONS.md` (ADRs), `ASSUMPTIONS-AND-OPEN-QUESTIONS.md`
- **Decision keys:** `ADR-xxx` = formal decision record; `AD-x` = discovery's open-decision IDs; `AQ-x`/`OQ-x` = assumption/question IDs.
## Relationship to DISCOVERY.md
No substantive disagreements with the discovery findings. This document **resolves** discovery's open decisions AD-1…AD-14 and technical questions TQ-1…TQ-14 where evidence allows, and explicitly narrows a few discovery positions:
| Discovery item | Architecture resolution | Nature |
|---|---|---|
| TQ-9 (Ed25519 WebCrypto on iOS 16.4 baseline) | WebCrypto Ed25519 is **not** reliably present on the iOS 16.4 baseline. Resolved by bundling a small, audited pure-JS Ed25519 verifier in the app shell instead of relying on WebCrypto. See ADR-013. | Resolution |
| AD-3 candidate: SQLite-in-WASM | **Rejected** for V1 (complexity, WASM memory risk on iOS, no requirement that needs it). IndexedDB selected. ADR-004. | Decision |
| AD-2 candidate: Workbox | **Rejected** in favor of a small hand-written service worker. ADR-002 rationale in §8. | Decision |
| Discovery R-M4 "geolocation may be used" | Narrowed: **no geolocation in V1 at all** (not even optional blue dot). GPS remains enhancement-only per invariant 9; schema keeps a hook. §15. | Narrowing |
| Discovery A-02 budget "≤ 50 MB" | Kept as hard ceiling; refined into per-part budgets totaling ≤ 40 MB target. §10.6, §27. | Refinement |
| Discovery OF-3 embedded emergency baseline | **Adopted** as a formal decision (ADR-007), with the refinement that the baseline is *generated from the same emergency source content* as the dataset to prevent drift. §13. | Adoption + refinement |
| Discovery §15.7 clock recommendation | Adopted: UTC storage + festival IANA zone + persisted server-time offset + wrong-clock heuristics. §14, ADR-009. | Adoption |
| SPIKE-08 F-3 sanity-window suppression | Sanity warning suppressed until near festival (`now ≥ start−45d` or skew captured) to avoid noisy early-prep warnings. §14.3, ADR-009. | Refinement (validation) |
| SPIKE-03 F-1 map caps | Overview ≤1600 px / detail ≤3072 px / total ≤~35 MB — replaces 4096 cap. §15, §27, ADR-008. | Refinement (validation) |
| SPIKE-01 P1–P8 + SPIKE-02 F-1…F-4 | A/B transactional protocol hardening (per-file txn, verification record, readbackPending, rollback depth 1). §9.2, §18. | Refinement (validation) |
| SPIKE-04 F-1…F-4 package schema | `required` flag, no-expiration rule, emergency sub-versioning, user-data schema split. §10. | Refinement (validation) |
| SPIKE-05 offline-ready formalization | Predicate C1–C8 + time independence + FAILED state + six-state taxonomy. §12. | Formalization |
| SPIKE-06 emergency floor hardening | Fixed tier-1 contents, zero-IDB forward-tolerant renderer. §13, ADR-007. | Refinement (validation) |
| SPIKE-07 bootstrap L0–L2 | Explicit levels + ordered download + minimum safe L1 + tab-before-install allowed. §11/§12, ADR-002. | Refinement (validation) |
---
# 1. Executive Summary
Lumen is an **offline-first, mobile-first Progressive Web App** for 300–500 festival attendees. The entire critical experience (Emergency, Schedule, Map, Festival info, My Schedule) must work in airplane mode after a one-time online preparation step.
The architecture in one paragraph:
> A small, framework-free TypeScript app shell is served from static HTTPS hosting and cached by a **minimal hand-written service worker**. Festival content is delivered as a **signed, versioned Festival Data Package** (JSON sections + assets + signed manifest), downloaded by an app-layer sync service through a **transport abstraction** (V1: HTTPS pull-only), stored in **IndexedDB using an A/B dual-slot model**, and activated only after full integrity verification via a **single atomic metadata transaction**. A minimal **emergency baseline is compiled into the app shell itself**, so emergency information survives even total storage loss. An explicit, evidence-based **OFFLINE READY state machine** reports readiness honestly. No accounts, no server-side state, no mesh — only a clean seam where future transports could attach.
Key decisions (details in ADRs):
| Area | Decision | ADR |
|---|---|---|
| Offline posture | Offline-first; local data is the source of truth | ADR-001 |
| Platform | Installed-first PWA, single origin, HTTPS | ADR-002 |
| Frontend | Vanilla TypeScript, no framework | ADR-003 |
| Storage | IndexedDB (thin internal wrapper) + Cache Storage for shell | ADR-004 |
| Data format | JSON sections + assets + signed manifest (Festival Data Package v1) | ADR-005 |
| Updates | A/B dataset slots, hash-verified staging, atomic pointer flip, retained rollback slot | ADR-006 |
| Emergency | Embedded immutable baseline + signed dataset overlay | ADR-007 |
| Map | Raster WebP base (≤2 zoom levels) + data-driven DOM POI overlay + list view | ADR-008 |
| Time | UTC instants + festival IANA timezone + persisted server-time offset | ADR-009 |
| Sync | Pull-only, on-open + opportunistic; no accounts; monotonic versions | ADR-010 |
| Networking | Transport interface under a Sync Service; V1 ships one HTTP implementation | ADR-011 |
| Mesh | Future-only; seam via signed-payload-over-transport rule | ADR-012 |
| Security | Ed25519-signed manifests, TLS+HSTS, CSP, content-as-data, data minimization | ADR-013 |
| Deployment | Static CDN hosting + content repo + automated sign/publish pipeline | ADR-014 |
---
# 2. Architectural Goals
| # | Goal |
|---|------|
| G-1 | Critical functionality works with zero connectivity (invariants 1–4). |
| G-2 | The app can always answer, with evidence, whether it holds a complete verified dataset (invariant 8). |
| G-3 | Updates are atomic; a last-known-good dataset always survives failures (invariants 5–7). |
| G-4 | Emergency information exists even after total storage loss (invariant 2). |
| G-5 | Minimal operational surface for a small team: static hosting, no backend state, no accounts (discovery A-17). |
| G-6 | Honest degradation: every failure mode has a defined, non-blank user-visible state. |
| G-7 | A future transport (incl. mesh) can be added without rewriting features (invariants 11–12). |
| G-8 | Smallest architecture that satisfies the above (invariant 16 / discovery principle 15). |
# 3. Architectural Constraints
Binding constraints (from the brief + discovery §5, ratified):
1. Critical V1 features work offline. 2. Emergency baseline always local. 3. Startup never needs network. 4. App keeps functioning after connectivity loss. 5. Last valid dataset survives failed updates. 6. Updates atomic. 7. Never knowingly expose a partial dataset. 8. Offline readiness explicitly verifiable. 9. GPS never required. 10. Network = enhancement. 11. No mesh in V1. 12. Mesh concerns never leak into features/UI. 13. Content = versioned data. 14. iOS Safari storage/lifecycle limits are design inputs. 15. Android Chrome behavior is a design input. 16. Fail safely when optional capabilities are missing.
Derived constraints (ratified from discovery, carried forward):
- **C-16 Install-first:** storage persistence and any future push on iOS hinge on home-screen install → install coaching is architecture-driving UX.
- **C-17 Bootstrap honesty:** offline capability requires one successful online bootstrap.
- **C-18 Eviction survival:** app must detect wiped storage and enter a recovery state, never a blank screen.
- **C-19 Foreground-only:** nothing depends on background execution, Background Sync, or push wakeups.
- **C-20 Single origin:** shell, data endpoints, and assets all live on one HTTPS origin.
New constraints introduced by this phase:
- **C-21 Document-context downloads:** festival package downloads run in the page (document) context, not the service worker, because iOS terminates service workers aggressively and pages can resume staged downloads. The SW stays tiny.
- **C-22 Content is never rendered as HTML:** festival content is structured data rendered through an escaping renderer; no raw HTML ingestion (security).
- **C-23 Compatibility envelope:** app shells support a declared range of data schema versions; publishers never release a schema outside the deployed envelope (ordering rule, §18.6).
- **C-24 No secrets client-side:** no private keys, credentials, or API keys ever ship in the app.
# 4. System Context
```mermaid
flowchart TD
subgraph Attendee Device
UI[Lumen PWA<br/>UI + domain services]
SW[Service Worker<br/>shell cache only]
IDB[(IndexedDB<br/>dataset slots + user state)]
CS[(Cache Storage<br/>app shell)]
end
subgraph Festival Organization
CR[Content repo<br/>schedule / map / emergency / info]
PB[Publish pipeline<br/>validate, hash, sign]
end
CDN[Static HTTPS CDN<br/>app shell + signed packages]
CR --> PB --> CDN
CDN -. HTTPS pull .-> UI
UI <--> IDB
UI <--> CS
SW <--> CS
FUTURE[Future transport<br/>local network / mesh / native companion]
FUTURE -. future only, signed payloads .-> UI
```
Actors and responsibilities:
| Actor | Responsibility |
|---|---|
| Content repo | Authoritative source of all festival content (discovery AMB-2 default). |
| Publish pipeline | Schema validation, size budgets, hashing, Ed25519 signing, upload, pointer update, smoke check. Humans press "publish"; machines do everything else. |
| Static CDN | Immutable versioned packages + mutable `latest.json` pointer + app shell. No logic. |
| PWA | Everything else: storage, verification, readiness, UX. |
| Future transport | Delivers the *same signed payloads* by another route; verification never moves. |
# 5. Recommended Technology Stack
| Concern | Selection | Rationale (short) | ADR |
|---|---|---|---|
| Language | TypeScript (strict) | Type safety for data-heavy code; owner familiarity (discovery §1.4); no runtime cost. | ADR-003 |
| UI framework | **None** — vanilla TS, small internal store + router | Smallest bundle, zero dependency churn, festival-app longevity, low-end perf. Fallback: Preact if UI complexity outruns the model. | ADR-003 |
| Build | Single-bundle bundler (esbuild/vite-class) producing hashed immutable assets | Implementation-phase pick; constraint: hashed filenames + precache manifest output. | — |
| Service worker | Hand-written, ~small, precache + navigation fallback only | Full control of lifecycle; no library surface. | ADR-002 |
| Structured storage | IndexedDB via thin internal promise wrapper | Structured data, transactions, Blob storage, quota-friendly. No external DB library; no SQLite-WASM. | ADR-004 |
| Small flags | localStorage (try/catch-guarded) | Convenience only; never load-bearing. | ADR-004 |
| Shell caching | Cache Storage | Standard app-shell model. | §8 |
| Crypto | WebCrypto SHA-256 + bundled audited pure-JS Ed25519 verifier | Baseline-device coverage incl. iOS 16.4. | ADR-013 |
| Map rendering | WebP raster base + DOM POI overlay + CSS-transform pan/zoom | Perf on low-end, a11y, organizer workflow. | ADR-008 |
| Hosting | Static HTTPS CDN | No server state needed. | ADR-014 |
| Push / notifications | None in V1 | iOS fragility; pull model suffices. | ADR-010 |
| Analytics/telemetry | None; on-device diagnostics only | Data minimization. | ADR-013 |
| Testing | Unit + contract + scripted device matrix | §26. | — |
Deliberately **absent**: React/Angular/Vue, Workbox, Dexie/idb, SQLite-WASM, any map SDK, any mesh library, any account/auth system, any dynamic backend.
# 6. Application Architecture
## 6.1 Layering and boundaries
```mermaid
flowchart TD
subgraph UI Layer
V["Views: Emergency / Schedule / Map / Festival / Status+Settings"]
VM[Presentation stores]
end
subgraph Domain Services
EM[EmergencyService]
SCH[ScheduleService]
MAP[MapService]
INF[InfoService]
RD[ReadinessService]
CLK[ClockService]
FAV[FavoritesService]
end
subgraph Data Layer
DS[DatasetStore read API]
US[UserStore favorites/prefs]
end
subgraph Sync Layer
SY[SyncService orchestration: check, stage, verify, activate]
TI[Transport interface]
HT[HttpTransport V1]
VF[Verifier hashes + Ed25519]
end
subgraph Platform Layer
IDB[(IndexedDB adapter)]
CACHE[(Cache Storage adapter)]
SWB[SW bridge page-side]
end
SW[Service Worker separate context]
V --> VM --> EM & SCH & MAP & INF & RD
SCH --> CLK
EM & SCH & MAP & INF --> DS
VM --> FAV --> US
SY --> TI --> HT
SY --> VF
SY --> DS
DS --> IDB
US --> IDB
SWB --> SW
V --> RD
```
Boundary rules (enforced by module imports; verified in review):
| Rule | Meaning |
|---|---|
| B-1 | Views never touch IndexedDB, `fetch`, Cache Storage, or SW internals. |
| B-2 | Domain services only read through `DatasetStore`/`UserStore` read APIs. |
| B-3 | Feature modules never know which transport delivered data; transports never know what content means. |
| B-4 | `Verifier` is the only component that decides dataset authenticity; Sync orchestrates, never validates by itself. |
| B-5 | The service worker never writes dataset data; dataset staging lives in the page context (C-21). |
| B-6 | User state (favorites, prefs) lives in a separate store that dataset updates/rollbacks/GC can never touch. |
| B-7 | No festival content is rendered via innerHTML with raw strings (C-22). |
## 6.2 Screen structure
- Single-page app, one cached `index.html`, client-side history-API router with offline-safe deep links (e.g., `/emergency`, `/map`).
- Persistent bottom navigation with four destinations: **EMERGENCY · SCHEDULE · MAP · FESTIVAL** (Emergency visually dominant, first position). Because the nav is persistent on every screen, Emergency is one tap from anywhere (R-N3) without any additional overlay machinery.
- A persistent **status chip** (OFFLINE READY / PARTIAL / NOT READY / RECOVERY) lives in the app header and opens the Status screen (§12).
# 7. PWA Architecture
| Element | Design |
|---|---|
| Web app manifest | `display: standalone`, theme/background colors, maskable icons, `scope` = origin root, `start_url` = `/`. |
| Install strategy | Install-first (C-16): install coach on iOS (manual steps with visuals), `beforeinstallprompt` handling on Android where available, install-state detection (`display-mode: standalone` / `navigator.standalone`). Coach is dismissible but re-surfaced until installed or dataset is ready. |
| Origin model | Single origin (C-20). No cross-origin assets. |
| HTTPS | Mandatory (SW + Storage + Geolocation require secure context). HSTS enabled. |
| Standalone-mode handling | In-app back affordances; external links open with explicit handoff; `viewport-fit=cover` + safe-area insets. |
| Private browsing | Detected where possible; app still renders shell + embedded emergency baseline; storage attempts are guarded and produce a clear "cannot save offline data" state (§22, FA-7). |
| Permissions | V1 requests **no permissions** (no geolocation, no notifications). Zero-permission by default is a feature. |
| iOS specifics | 7-day eviction exempt only when installed → install coaching + re-bootstrap recovery; no background sync assumption; SW kept minimal. |
**What the web platform guarantees vs practice vs must-verify** (required distinction):
| Topic | Guaranteed by spec | Works in practice (as of 2026) | Must verify on real devices |
|---|---|---|---|
| SW precache of shell | Yes, on supporting browsers | Yes, iOS+Android | Cache survival across reboots; update activation timing on iOS |
| IndexedDB persistence | **Best-effort only** — never guaranteed | Generally persists for installed PWAs | Eviction behavior under storage pressure; IDB transaction stability near quota on iOS |
| Storage eviction notice | None (eviction is silent) | Silent on both platforms | Our detection path (boot verification) is the only mechanism |
| `navigator.storage.persist()` | API exists; grant is heuristic | Auto-granted for installed PWAs on Chrome; heuristic on WebKit | Grant rate on target iOS versions |
| Offline launch after reboot | If SW + caches intact | Yes | Cold-start timing on low-end Android |
| Background work after app hidden | **Nothing guaranteed** | iOS kills quickly; Android throttles | Assume zero background (C-19) |
| Push | Spec exists | iOS: installed-only, region-dependent | Not relied on (V1: none) |
| Geolocation | Requires permission + secure context | Accuracy degrades in crowds | Not used in V1 |
| `tel:` links | Standard | Works incl. standalone | Verify on iOS standalone + Android |
| Intl timezone rendering | Yes (IANA zones) | Yes | Target devices + festival zone edge cases |
# 8. Service Worker Strategy
**Scope of the SW (deliberately tiny):**
1. `install`: precache the versioned app-shell list (index.html, hashed JS/CSS, icons). Precache list is generated at build time.
2. `activate`: delete caches from previous shell versions.
3. `fetch`:
- Navigation requests → **cache-first** against the shell cache; fallback to network only to re-fill; if both fail, a built-in static fallback page (rendered from shell bytes) with emergency text.
- Hashed static assets (`/assets/*` immutable URLs) → cache-first.
- `/latest.json`, `/editions/**` (package endpoints) → **not intercepted**; they go straight to network so the app-layer sync controls caching/staging (no double cache, no SW-held partial downloads; C-21).
4. `message`: minimal commands (`SKIP_WAITING`) from the page.
**What the SW deliberately does NOT do:** dataset download/staging, background sync, retry logic, announcements, anything stateful beyond caches.
**Shell update policy:**
- New shell version → new SW installs with a new cache name; old SW keeps serving current session.
- Activation happens on **next full app start** (no mid-session `clients.claim`), except when the user explicitly taps "Restart to update" on the Status screen. This protects in-progress festival sessions and in-progress dataset staging.
- After shell update, boot runs the **compatibility check** (§18.5) between new shell and active dataset before exposing data screens.
**SW termination handling (iOS reality):** every SW operation is idempotent and re-runnable; the page never depends on SW in-memory state; page-side logic resumes staged downloads regardless of SW lifetime.
# 9. Local Storage Architecture
## 9.1 Storage map
| Store | Technology | Contents | Lifecycle |
|---|---|---|---|
| `lumen-system` | IndexedDB (1 object store: `meta`) | Active slot pointer, active edition + packageVersion, verification record, app version at activation, staging progress pointer, clock offset cache reference | Rewritten atomically on activation |
| `lumen-slot-a`, `lumen-slot-b` | IndexedDB (stores: `files`, `assets`) | One complete dataset per slot: section JSON docs keyed by section id; assets as Blobs keyed by asset id | Active slot is truth; inactive slot = rollback/staging target; GC per §18.4 |
| `lumen-user` | IndexedDB (stores: `favorites`, `prefs`, `diag`) | Favorites keyed by stable event id; theme/clock settings; scrubbed diagnostics ring buffer | **Never touched by dataset updates or rollback** (B-6) |
| Shell cache | Cache Storage (`lumen-shell-v<build>`) | Precached app shell | Versioned per build; old caches deleted on activate |
| Flags | localStorage | Tiny convenience flags (coach dismissed, etc.), try/catch guarded | Non-load-bearing |
Rationale for all-IDB datasets (vs Cache API for assets): rollback and activation concern **one storage system**, one failure domain, one GC policy. IDB Blob storage is adequate at our asset budget (§10.6, ≤ ~30 MB assets).
## 9.2 Wrapper policy (validated, P1–P8 normative per SPIKE-01/02 — ARCHITECTURE-VALIDATION.md §2)
- Thin internal promise wrapper over raw IDB (~small module): typed get/put/transaction helpers. No external DB library (ADR-004): dataset access patterns are few and known; wrapper keeps IDB quirks (transaction lifetimes, `oncomplete` semantics) in one audited place.
- All writes that must be durable await transaction completion; no transaction spans an `await` of non-IDB work.
- Defensive protocol P1–P8 is mandatory: P1 one short txn per file (bytes+progress together); P2 activation single txn on `lumen-system`; P3 wrapped IDB + `QuotaExceededError` keeps active; P4 free-space pre-check 2× package; 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.
## 9.3 Quota and pressure management
- On boot and before staging: `navigator.storage.estimate()`; refuse to stage if free space < 2× package size; surface guidance.
- Request `navigator.storage.persist()` once after dataset ready (grant is heuristic; never relied upon).
- Hard budgets (§10.6) enforced by the publish pipeline, so client-side quota surprises are unlikely; client still handles `QuotaExceededError` by aborting staging and keeping the active dataset.
## 9.4 Eviction and unavailability
- Eviction is silent and all-or-nothing → **boot verification** (§12) is the detection mechanism: missing/corrupt system store ⇒ RECOVERY state; embedded emergency baseline still renders.
- Storage unavailable (private mode, disabled website data): all IDB access is wrapped; failure ⇒ BASELINE-ONLY mode (§22 FA-7) with a clear explanation and install/normal-mode guidance.
# 10. Festival Data Package Architecture
## 10.1 Package anatomy (normative V1)
```
Package (immutable, versioned directory on the CDN)
├── manifest.json ← section inventory, hashes, sizes, compatibility, festival metadata
├── signature.json ← Ed25519 signature over SHA-256 of exact manifest bytes + key fingerprint
├── emergency.json ← updateable emergency section (schema: emergency/1)
├── schedule.json ← stages, artists, events (schema: schedule/1)
├── map.json ← base-map level definitions + POIs in normalized coords (schema: map/1)
├── info.json ← festival information blocks (schema: info/1)
├── assets.json ← asset inventory (ids, roles, hashes, sizes)
└── assets/… ← WebP/PNG images, etc., each listed in assets.json
```
Plus, outside the package directory:
```
/latest.json ← mutable pointer: { edition, packageVersion, manifestUrl, generatedAt }
```
## 10.2 Manifest schema (sketch — normative fields)
```
{
"format": "lumen.package/1",
"edition": "<edition-id>", // e.g., "lumen-2026"
"packageVersion": <int, monotonic>,
"schemaVersion": <int>,
"generatedAt": "<UTC ISO8601>",
"festival": {
"name": "...",
"timezone": "<IANA zone>",
"startUtc": <epoch ms>, "endUtc": <epoch ms>
},
"appCompatibility": { "minAppVersion": "x.y.z", "maxAppVersion": null },
"sections": {
"emergency": { "file": "emergency.json", "sha256": "…", "bytes": n },
"schedule": { … }, "map": { … }, "info": { … }, "assets": { "file": "assets.json", … }
},
"counts": { "events": n, "pois": n, "assets": n },
"limits": { "totalBytes": n }
}
```
`assets.json` entries: `{ id, file, sha256, bytes, kind, role }` with `kind ∈ {map-base, poi-icon, photo, icon}` and roles like `map-base/overview`, `map-base/detail`.
## 10.3 Authoritative source & publishing (ADR-005, ADR-014)
- **Source of truth:** a content repository (git) holding source files: schedule sheet (CSV/JSON), POI sheet, emergency content sheet, info markdown-ish blocks, map artwork. Organizers edit sources; the pipeline does the rest (discovery AMB-2 default).
- **Pipeline:** validate (schema + budgets + ID stability + time sanity) → build section JSONs → hash → sign manifest → upload immutable package → update `latest.json` → automated smoke fetch + verify.
- **Emergency baseline generation:** the same emergency source sheet also generates the **embedded baseline** compiled into app shell builds (§13) — one source, two outputs, no drift.
- **Roles:** one human "publisher" role; every publish is logged in the repo history (audit trail). Emergency content changes require the sign-off gate (discovery A-08/OQ-2).
## 10.4 Versioning rules
- `packageVersion` is a strictly monotonic integer per edition. Clients **never accept a lower version from a network source** (downgrade protection). Organizer "rollback" = publish old content under a **new** higher version (runbook, §18.7).
- `edition` identifies one festival occurrence. V1 keeps one active edition; a new edition reuses the slot pair (old edition GC'd at activation of the new one).
- `schemaVersion` changes only with the compatibility envelope rules (§18.6).
## 10.5 Integrity model (ADR-013)
1. Every file's SHA-256 + byte size is listed in the manifest.
2. The manifest's exact bytes are hashed; `signature.json` holds an Ed25519 signature over that digest made with the festival's offline signing key.
3. The app shell embeds the **public key set** (fingerprinted; small set to allow rotation).
4. Client verification order: signature → manifest parse → per-file size+hash → section schema validation. Any failure ⇒ reject package, keep current dataset.
5. Verification results are recorded in `lumen-system.meta` (what, when, which version) so boot can do a cheap light-check and a full re-check on demand.
## 10.6 Size budgets (hard ceilings, enforced by pipeline)
| Part | Budget |
|---|---|
| App shell (JS+CSS+icons, gzipped) | ≤ 1 MB |
| Embedded emergency baseline | ≤ 16 KB |
| Sections JSON total (emergency+schedule+map+info+assets.json) | ≤ 3 MB |
| Map base images (all levels) | ≤ 28 MB |
| All other assets | ≤ 6 MB |
| **Total dataset** | **≤ 40 MB** (hard ceiling 50 MB per discovery A-02) |
Single file cap: 6 MB (keeps per-file hash/digest memory bounded).
# 11. Offline Architecture
The offline story has three rings:
1. **Ring 0 — Embedded in shell bytes (survives total storage loss):** static fallback page + emergency baseline (§13) + app code.
2. **Ring 1 — Cache Storage:** the full app shell (survives restarts; eviction follows browser policy).
3. **Ring 2 — IndexedDB:** active dataset slot (+ rollback slot) and user state.
**Cold start (no network ever):** SW serves shell from cache → app boots → boot sequence: open `lumen-system` → light verification of active slot → readiness state computed → UI renders with whatever is verified. No network call is required or blocking at any point (invariant 3).
**Connectivity model:** the app listens to `online`/`offline` events only as *opportunistic hints*; every feature renders from local stores regardless. Being "online" never unlocks critical UI; it only enables the sync service.
# 12. Offline Ready Architecture
## 12.1 Definition (normative)
`OFFLINE READY` is a **proven state, not a connectivity statement**:
```
READY ⟺
R1 Shell complete: every precache entry present in current shell cache
∧ R2 Dataset present: active slot exists for the edition
∧ R3 Manifest verified: signature valid against embedded key set, verified record matches active packageVersion
∧ R4 Sections complete: all manifest-listed section files present with matching sizes; activation-time hashes passed
∧ R5 Assets complete: all assets present with matching sizes (hashes verified at staging)
∧ R6 Compatibility: schemaVersion within this shell's supported range
∧ R7 Emergency: baseline present (trivially true — embedded) AND dataset emergency section present
```
Anything less is reported precisely:
| State | Meaning |
|---|---|
| `READY` | All of R1–R7. |
| `PARTIAL(x…)` | Enumerated missing parts (e.g., map assets only). App usable for what's present. |
| `NOT_READY` | Shell present, no verified dataset (fresh install / pre-prep). |
| `RECOVERY` | Previously verified state now fails checks (eviction/corruption). |
| `BASELINE_ONLY` | Storage unavailable; only embedded shell+baseline function. |
## 12.2 Verification cadence
| When | Check |
|---|---|
| Every boot | Light check: slot existence, sizes, recorded verification matches active version (fast; no re-hash). |
| After staging completes | Full verification (all hashes + schema). |
| At activation | Recorded; readback spot-check. |
| User-initiated ("Check my data") | Full re-verification with progress. |
| After any IDB error | Full re-verification of affected slot; quarantine on failure. |
## 12.3 UX contract
- Status chip visible on every screen; Status screen shows per-section checklist, dataset version, `generatedAt`, `fetchedAt`, storage usage, and the "Check my data" / "Get festival data" / "Restore previous version" actions.
- The app **never shows READY** unless the predicate above is met (invariant 8; discovery AMB-5 default).
## 12.4 Preparation (bootstrap) flow
```mermaid
sequenceDiagram
participant U as Attendee
participant A as Lumen (page)
participant S as Static CDN
U->>A: Opens link / scans QR (first visit)
A->>S: Load shell (SW precaches)
A->>A: Install coach (esp. iOS); readiness = NOT_READY
U->>A: "Get festival data"
A->>S: latest.json → manifest.json
A->>A: Verify signature + compatibility + budgets
A->>S: Download sections (priority: emergency → schedule → info → map)
A->>A: Per-file hash verify, stage into inactive slot (resumable)
A->>S: Download assets (resumable)
A->>A: Full verification → atomic activate → readiness = READY
A->>U: "OFFLINE READY ✓" confirmation + summary
```
Answers to the eleven bootstrap questions:
1. **Discovery:** QR codes on tickets/emails/posters + short URL; identical landing page. (Ops detail OQ-3; architecture is URL-based.)
2. **Initial load:** shell only (~1 MB), instant precache; app is usable but `NOT_READY` for festival content; emergency baseline already works.
3. **Dataset acquisition:** explicit "Get festival data" preparation flow; prioritized, resumable, progress per section; runs while app is open (C-19).
4. **Existence verification:** manifest completeness check (all listed files present).
5. **Integrity verification:** §10.5 order; failures abort activation.
6. **User knows readiness:** READY confirmation screen + persistent status chip; per-section checklist on Status.
7. **Incomplete preparation:** PARTIAL state enumerates exactly what's missing; every installed part works; prep resumes with one tap.
8. **Leaves prep early:** staging progress persisted (`lumen-system.meta.staging`); nothing is corrupted; nothing is activated; resume later.
9. **Connectivity disappears mid-prep:** downloads pause; partial staged data retained; offline state shown with what's already usable; auto-resume when `online` hint fires or user retries.
10. **Storage unavailable:** BASELINE_ONLY mode; clear guidance (install to home screen / use normal browsing mode / free space); no crash, no blank screen.
11. **Later eviction:** boot light-check fails ⇒ RECOVERY: emergency baseline works; one-tap full re-prep when online; honest messaging that festival data was removed by the device.
# 13. Emergency Architecture
## 13.1 Three tiers (ADR-007)
```mermaid
flowchart TD
REQ[Emergency screen request] --> T1{Dataset emergency<br/>verified & compatible?}
T1 -- yes --> SHOW[Render dataset emergency content<br/>with version + updated-at stamp]
T1 -- no --> T2{Embedded baseline present?}
T2 -- always yes --> BASE[Render embedded baseline<br/>labeled BASELINE]
SHOW --> LIVE{Optional live emergency notice<br/>in announcements inbox?}
LIVE -- yes, signed, unexpired --> SHOW2[Append, clearly labeled]
LIVE -- no --> DONE[Done]
BASE --> DONE
SHOW --> DONE
```
| Tier | Content | Mutability | Delivery | Survives |
|---|---|---|---|---|
| **T1 Embedded baseline** | Emergency number + dial, festival address, coordinates, security contact, first-aid/AED location summaries, muster points, exit summaries, core procedures (weather/fire/lost person/medical) | **Immutable per app build** — compiled into shell from the emergency source sheet at build time | Ships with app shell | **Everything**, incl. total storage loss, mid-update failure, incompatible dataset |
| **T2 Dataset emergency section** | Full detail: all POI-class emergency locations with map links, full procedure text, notices, per-role contacts | Updateable via signed dataset updates | Festival Data Package | Last-known-good dataset |
| **T3 Optional live notice** | Urgent one-liners (e.g., "muster point moved to North field") | Ephemeral, signed, expiring | Announcements seam (§17.4) — **deferred; not required in V1** | Only if delivered |
**Tradeoff analysis (embedded baseline):**
- *Pros:* absolute floor — emergency info exists even after eviction, failed updates, or corrupted storage; zero runtime dependency.
- *Cons:* baseline changes require an app shell release (slower); baseline must stay small (≤16 KB).
- *Verdict:* the floor's value dominates; the drift risk is removed by generating baseline + dataset section from the **same source sheet** (§10.3). Baseline staleness is bounded by shell update cadence and labeled with its version.
## 13.2 Rules
- Emergency rendering path has **no network calls, no IDB schema surprises** (baseline path parses a frozen schema compiled into the build).
- `tel:` links primary; numbers also displayed as selectable text (copy). Dialing requires an explicit tap; never auto-dial.
- Every emergency screen shows content provenance: `BASELINE v<app>` and/or `FESTIVAL DATA v<package> · generated <date>`.
- If T3 cannot be delivered, nothing degrades: T1/T2 remain (documented acceptance, discovery A-16).
# 14. Schedule Architecture
## 14.1 Data model
- `schedule.json`: `stages[]`, `artists[]`, `events[]`.
- Event: `{ id (stable), title, description?, stageId, artistIds[], startUtc, endUtc, tags[], status: scheduled|moved|cancelled, originalStartUtc?, dayKey }`.
- **Stable IDs are contractual**: updates mutate fields, never IDs (favorites depend on this, §15 of discovery).
- `dayKey` is derived at publish time from festival-tz calendar date (precomputed so the client never computes day boundaries ambiguously).
## 14.2 Queries (all local)
- **Now / Up Next:** computed from `ClockService.now()` (§14.3): events where `startUtc ≤ now < endUtc`; next-N by stage or global.
- **My Schedule:** favorites store joined to events; conflict detection (overlapping intervals) surfaced in UI.
- **Filters/search:** stage/tag filters + substring search over title/artist/description; dataset size (≤ ~1k events) makes naive scanning fine — no index library (discovery TQ-10 resolved: skip until proven slow).
- **Change surfacing:** events with `status=moved|cancelled` render distinctly; dataset update notes may list changed event ids (optional manifest field, cheap).
## 14.3 Time model (ADR-009 — addendum after SPIKE-08, ARCHITECTURE-VALIDATION.md §2; F-3/F-4 normative)
**Storage:** all instants are UTC epoch ms. The manifest carries the festival IANA zone and festival start/end instants.
**Validation:** UTC+explicit-zone `Intl` + precomputed `dayKey` + skew+monotonic + sanity window proven 24/24 on V8/ICU (`SPIKE-08:43`); JSC parity queued for D1/D2 device matrix. F-3: sanity warning suppressed until near festival; F-4: only `Intl.DateTimeFormat` with explicit `timeZone` allowed.
**Rendering:** `Intl.DateTimeFormat` with `timeZone = festival zone` by default; user setting toggles to device zone. Day boundaries come from precomputed `dayKey`. DST correctness is inherited from the IANA zone database in the OS/browser.
**The clock problem:** device clocks can be wrong, and there is no network time offline.
```mermaid
flowchart TD
N[ClockService.now] --> H{Persisted server offset<br/>available?}
H -- no --> D[Use device clock]
H -- yes --> C[now = device + skew]
C --> DR{Mid-session drift check:<br/>device clock moved vs<br/>monotonic expectation?}
DR -- yes --> W[Warn: clock changed;<br/>re-derive base, keep server skew if present]
D --> SAN{Sanity: now within<br/>festival window ± 45 days?}
SAN -- no --> W2[Warn: check your clock]
C --> SAN
```
- **Offset capture:** whenever any sync HTTP response arrives, read its `Date` header; `skew = serverNow − deviceNow`; persist `{skew, capturedAtDevice, capturedAtMono, source}` in the user DB. Use corrected time thereafter.
- **Monotonic anchor:** `performance.now()` (session) guards against the user changing the device clock mid-session.
- **Sanity-window suppression (SPIKE-08 F-3 — normative):** warn on `outsideWindow` only if `now ≥ festivalStart − 45d` or a server skew has previously been captured; otherwise early preparation (e.g., July for September festival) would spuriously warn every user. Dataset `generatedAt`/`fetchedAt` communicates staleness before that point (ARCHITECTURE-VALIDATION.md §2).
- **Never-online devices:** fall back to device clock with the sanity-window heuristic (now with F-3 suppression); if wrong, Now/Next may be wrong and **the app cannot know** — accepted residual risk, mitigated by showing dataset `generatedAt` and event times absolutely (users can still read times).
- No NTP-style complexity, no background timers: the clock is computed on demand.
# 15. Map Architecture (ADR-008)
## 15.1 Decision summary
**Raster WebP base map (≤ 2 zoom levels) + data-driven DOM POI overlay + CSS-transform pan/zoom + list-based equivalent view.** No online tiles, no map SDK, no GPS in V1.
## 15.2 Evaluation performed
| Criterion | Single SVG map | Raster base + DOM overlay (chosen) | Canvas |
|---|---|---|---|
| Organizer workflow | Needs vector art creation (rare skill; illustrated maps are raster) | Organizers provide illustrated art as-is (matches discovery AMB-3 default) | Same as raster |
| Low-end pan/zoom perf | Degrades with path complexity | GPU image transform — cheap and consistent | Good, but redraw cost on every frame |
| Accessibility | Interactive SVG a11y is fiddly at scale | POIs are real buttons/links; list view is first-class | Poor (off-screen text tree needed) |
| File size | Can be small or huge depending on art | WebP is excellent for illustrated art | Same as raster |
| Zoom quality | Infinite | Bounded by levels; 2 levels + browser scaling acceptable for venue scale | Same as raster |
| Code complexity/attack surface | Medium | Low | Higher (manual hit-testing) |
| Offline | Yes | Yes | Yes |
## 15.3 Mechanics
- **Coordinate space:** POIs carry normalized `{x: 0..1, y: 0..1}` relative to base-image native dimensions (authoring: coordinate sheet or a trivial tap-a-point tooling step in the pipeline; assumption D-02).
- **Levels:** `overview` (always stored; ~0.3–0.8 MB, longest edge ≤1600 px, ~7.3 MB decoded) and optional `detail` (high-res, ≤ 6 MB per file cap; shown when scale exceeds threshold; longest edge ≤3072 px, ~27 MB decoded; total decoded ≤~35 MB with at most one detail resident — SPIKE-03 F-1). Replaces prior ≤4096 px cap. `ARCHITECTURE-VALIDATION.md §2` reconciles.
- **Pan/zoom:** pointer events + pinch → single container `transform: translate(…) scale(…)`; markers counter-scaled to constant on-screen size; bounds clamped to map edges.
- **Interactivity:** POI tap → detail sheet (name, category, description, "Show in list"); category filter chips; POI search shares the search component with schedule.
- **Accessibility & GPS-free use:** the **Facilities list** (grouped by category, search-able) is a first-class view, not a fallback — it is how a screen-reader user, or anyone, finds "nearest water" without GPS.
- **GPS:** absent in V1 (narrowing of discovery R-M4). Hook preserved: POI schema allows optional `lat/lng` for future positioning; adding a blue dot later touches MapService only, never features (invariant 9 respected).
## 15.4 Failure behavior
- Map section missing (PARTIAL state) → list view still available if POI data present; otherwise "Map not downloaded" card with prep action. Map absence never affects Emergency/Schedule/Info.
# 16. Festival Information Architecture
- `info.json` = ordered list of blocks: `{ id, title, kind, body: structured nodes }` where nodes are paragraphs/lists/links/emphasis/contact/address/hours tables. **No raw HTML** (C-22); renderer maps node types to safe DOM.
- Categories (data-driven, reorderable by content): About, Rules, FAQ, What to bring / Not to bring, Parking, Camping, Transport, Accessibility, Food/Drink, Merch, Activities, Contacts, Hours, Venue.
- Search: same search component indexes info titles + text.
- Static-important guarantee: info is part of the signed dataset; readiness includes it; baseline-only mode excludes it (acceptable — emergency floor is what must survive).
# 17. Synchronization Architecture
## 17.1 Model (ADR-010, ADR-011)
**Pull-only. No accounts. No server state. Versions are monotonic.**
Triggers: app open; `online` event (opportunistic); manual "Update now"; never timers in background (C-19).
```mermaid
flowchart TD
T[Trigger: open / online / manual] --> O{Online hint?}
O -- no --> END[No-op]
O -- yes --> L[Fetch latest.json]
L --> CMP{Version > active<br/>same edition?}
CMP -- no --> END
CMP -- yes --> M[Fetch candidate manifest]
M --> V1{Signature valid?<br/>Keys known?}
V1 -- no --> REJ[Reject + quarantine version + diag]
V1 -- yes --> V2{Compatible with shell?<br/>Within budgets?}
V2 -- no --> REJ2[Reject; if newer app needed:<br/>prompt app update]
V2 -- yes --> STG[Stage files into inactive slot<br/>resumable, per-file verify]
STG --> FULL[Full verification]
FULL -- fail --> REJ3[Discard staging; keep active]
FULL -- ok --> ACT[Atomic activate §18]
ACT --> OK[New dataset active; READY re-evaluated]
```
## 17.2 Transport abstraction (ADR-011)
```
Application (features)
│ reads DatasetStore; sees readiness — nothing else
Data / Domain layer
│ DatasetStore, Verifier
Sync layer
│ SyncService: orchestrate(check → stage → verify → activate)
│ Transport interface:
│ isAvailable() → bool
│ fetchPointer(edition) → PackagePointer | none
│ fetchBytes(path) → Blob (resumable, size-capped)
Transport implementations
├── HttpTransport (V1, the only one)
└── <future: local-network / mesh / companion-bridge transports>
```
Rules:
- Features never import the sync or transport layer (B-3).
- Transports move **bytes**; the Verifier decides **truth** — identically for every transport (the signed-payload rule, ADR-012).
- The interface is intentionally byte-oriented and tiny; mesh semantics (discovery, routing, dedupe) live inside a future transport implementation, never in SyncService.
- Cost control: in V1 this is one interface + one implementation; no registry, no plugin machinery, no config system.
## 17.3 Announcements seam (deferred, schema reserved)
- Announcement shape reserved: `{ id, publishedAtUtc, expiresAtUtc, severity, title, body(nodes), signature }`, delivered either inside a package (section `announcements`) or via the same transport as a signed sidecar file.
- V1 decision: **schema reserved, feature not implemented** unless schedule-change pressure demands it during build (discovery A-12). Emergency notices would render per §13 tier T3 rules.
## 17.4 What sync never does in V1
No uploads, no user-state sync, no telemetry, no push registration, no background sync registration.
# 18. Update / Rollback Architecture (ADR-006)
## 18.1 A/B slot state machine
```mermaid
stateDiagram-v2
[*] --> Idle
Idle --> Staging : newer verified manifest found
Staging --> Staging : file downloaded + hashed (progress persisted)
Staging --> Verified : all files staged + full verification passed
Staging --> Idle : interrupt / failure / quota (staging discarded or kept for resume; ACTIVE UNTOUCHED)
Verified --> Activating : single IDB transaction on lumen-system
Activating --> ActiveNew : pointer flipped + meta recorded
Activating --> Verified : transaction error (retry once)
ActiveNew --> Confirmed : readback spot-check OK, N successful boots
ActiveNew --> RolledBack : readback fails
RolledBack --> ActiveOld : pointer back to previous slot
Confirmed --> GC : previous slot reclaimable (kept until next staging needs it)
```
**Invariant (invariants 5–7):** at every moment `lumen-system.meta.activeSlot` points at either the old verified dataset or the new verified dataset. There is no third observable state. Staging writes only to the **inactive** slot.
## 18.2 Failure-stage analysis (all nine stages)
| Stage | Failure | Behavior | Active dataset |
|---|---|---|---|
| Fetch pointer | Network error | Silent no-op; retry next trigger | Untouched |
| Fetch manifest | Error/timeout | No-op | Untouched |
| Signature/format check | Invalid | Reject, quarantine this version (don't refetch-loop), diag entry | Untouched |
| Compatibility check | Incompatible | Reject; user-visible "please update app" if `minAppVersion` > current | Untouched |
| Staging download | Interrupted (app kill, reboot, SW kill, connectivity) | Progress persisted per-file; resume later; staging older than 7 days discarded | Untouched |
| Staging write | QuotaExceeded / IDB error | Abort staging, discard partial, warn + storage guidance | Untouched |
| Full verification | Hash/schema mismatch | Discard staging; quarantine version | Untouched |
| Activation transaction | IDB transaction failure | Retry once; else keep old; diag entry | **Old** |
| Post-activation readback | Spot-check fails | Automatic rollback to previous slot; diag | **Old (restored)** |
Quarantine: rejected packageVersions are recorded so the client doesn't retry a known-bad version until `latest.json` advances past it.
## 18.3 Rollback
- **Automatic:** post-activation readback failure (§18.2 last row).
- **User-initiated:** Status screen "Restore previous version" while the previous slot still exists.
- **Organizer-initiated (server-side):** publish old content as a new higher version (runbook) — clients accept it as a normal update. This is the canonical rollback path and keeps client logic simple.
## 18.4 Garbage collection
Previous slot is retained until: (a) the *next* staging needs the slot, or (b) ≥ N successful boots (N=3) have occurred AND storage pressure is detected. User data (`lumen-user`) is never GC'd.
## 18.5 Shell-vs-dataset compatibility at boot
Boot order: load shell → read `meta` → check `schemaVersion(active dataset) ∈ shell.supportedRange` AND `packageVersion` within manifest's `appCompatibility`. Out of range → limited mode: Emergency baseline + Status screen explaining "open once with internet to update" (and if a compatible *other* slot exists, prefer it).
## 18.6 Publishing ordering rule (C-23)
1. Schema changes ship in an app shell **first** (shells support a range, e.g., schemas {1,2}).
2. Datasets using the new schema publish **only after** the supporting shell has been live ≥ the update-propagation window.
3. Runbook forbids breaking schema bumps in the 7 days before the festival.
## 18.7 Runbooks (documented, not code)
Publisher runbook: publish, verify smoke check, monitor `latest.json`. Rollback runbook: republish old content at new version. Emergency-change runbook: content change → sign-off gate → package publish (+ future T3 notice).
# 19. Networking Abstraction
(Covered structurally in §17.2; this section fixes responsibilities.)
| Layer | Owns | Never touches |
|---|---|---|
| Application/UI | Rendering, user intent, readiness display | fetch, IDB, SW, transports |
| Domain services | Feature logic over verified data | persistence mechanics, network |
| Data layer (DatasetStore/UserStore) | Typed reads/writes of local truth | network, transports |
| Sync layer | Orchestration, staging, version rules | content semantics |
| Verifier | Authenticity + integrity verdicts | orchestration |
| Transport | Bytes in / bytes out, availability | parsing, verification, storage |
| Service worker | Shell cache + navigation fallback | datasets, sync |
# 20. Future Mesh Extension Strategy (ADR-012)
**V1 builds nothing here.** The strategy is:
1. **Feasibility posture:** assume a meaningful mesh will require *something beyond the browser* (native companion, hardware relay, organizer LAN service) because mobile browsers expose no BLE peripheral mode, no ad-hoc Wi-Fi, no background sockets, and iOS lacks Web Bluetooth entirely. The architecture already treats "transport" as external plumbing, so this doesn't force rework.
2. **The seam:** a future mesh transport implements the Transport interface (§17.2) — e.g., `MeshTransport` bridging to a native companion over a browser-accessible channel, or a LAN mirror acting as a package source. SyncService is unchanged; features are unchanged.
3. **Security rule (V1 already enforces):** payloads from *any* transport are dead bytes until the Verifier passes them. Malicious peers cannot inject content without the festival signing key.
4. **Concerns explicitly assigned to FUTURE work (not V1):** peer discovery, routing, store-and-forward, message identity/deduplication, replay protection (package monotonicity already covers replay at the dataset level), expiration, message-size adaptation, peer authentication, emergency broadcast semantics, conflict handling beyond version monotonicity.
5. **Anti-leak rule (invariant 12):** no feature/UI code may reference transport identity, connectivity modality, or peer concepts. Status UI shows "updated <when>", never "via what".
# 21. Security Architecture (ADR-013)
## 21.1 Threat model (prioritized, realistic first)
| # | Threat | Class | Defense |
|---|---|---|---|
| TH-1 | Tampered/malicious package (hosting compromise, MITM) | Realistic, high | Ed25519-signed manifest + per-file SHA-256 + TLS/HSTS. Activation impossible without valid signature. |
| TH-2 | Compromised publish path | Realistic, high | Signing key offline; publisher role singular; repo audit trail; smoke checks; rollback runbook. |
| TH-3 | Malicious/stale emergency data | Realistic, high | Emergency dataset section is signed like all content; baseline immutable per build and generated from signed-off source; provenance labels on screen. |
| TH-4 | Content injection (XSS) | Realistic, medium | C-22: structured-data rendering only; CSP disallows inline script; no eval. |
| TH-5 | Replay/downgrade of old signed package | Realistic, low-impact | Monotonic version acceptance rule (§10.4); organizer rollback only via new version. |
| TH-6 | DoS via oversized package | Medium | Budgets in manifest validated *before* download; per-file caps; quota pre-check. |
| TH-7 | User/location privacy leakage | Low by design | No accounts, no telemetry, no location capture in V1, all user state device-local. |
| TH-8 | Browser storage exposure (other apps, shared devices) | Low | No secrets stored; dataset is public festival info; favorites are innocuous; OS-level app isolation assumed. |
| TH-9 | SW/cache poisoning from other origins | Low | Single origin, same-origin caches, CORS not relied upon (all same-origin assets). |
| TH-10 | Future mesh injection | Future | Signed-payload rule (§20.3); mesh auth deferred to that work. |
| TH-11 | Dependency supply chain | Standard | Minimal deps (target: zero runtime deps beyond the audited Ed25519 verifier); lockfiles; CI. |
## 21.2 Platform hardening
- HTTPS + HSTS; no mixed content; single origin (C-20).
- CSP: `default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' blob: data:; connect-src 'self'` (final tuning during build).
- No third-party scripts/fonts/analytics.
- `tel:` only via explicit user action; numbers data-driven (discovery A-14).
- Signing key custody: offline key; app embeds a small **key set** (fingerprinted) enabling rotation via shell update. Lost key ⇒ ship new key set in shell (runbook documented).
- Data minimization as a rule: the client collects nothing; diagnostics are local, scrubbed, and user-initiated only.
# 22. Reliability Architecture
Behavior matrix (what happens / what stays usable / what the user sees / recovery / needs internet?):
| # | Failure | Behavior | Usable | User sees | Recovery | Internet? |
|---|---|---|---|---|---|---|
| FA-1 | Offline startup | Boot from cache+IDB; light verification | Everything verified | Normal UI + honest status chip | n/a | No |
| FA-2 | Browser restart | Same as FA-1 | Same | Same | n/a | No |
| FA-3 | Phone restart | Cold start path; budgets apply (§27) | Same | Same | n/a | No |
| FA-4 | SW restart/killed | SW stateless by design; page logic unaffected | Everything | Nothing | n/a | No |
| FA-5 | Storage evicted | Light check fails ⇒ RECOVERY | Emergency baseline | RECOVERY screen: what happened, one-tap re-prep | Re-prep | Yes (for re-prep) |
| FA-6 | Corrupt dataset | Verification/readback failure ⇒ quarantine/rollback | Last-good dataset or baseline | Status explains; data screens use last-good | Automatic | No |
| FA-7 | Storage unavailable | BASELINE_ONLY | Emergency + shell | Explanation + guidance (install/normal mode) | Follow guidance | No |
| FA-8 | Interrupted update | §18.2 | Active dataset | Nothing (or resume banner) | Resume | Yes |
| FA-9 | Invalid package published | Client rejects; quarantine | Active dataset | Nothing (diag) | Organizer fixes + republishes | No (client side) |
| FA-10 | Wrong device clock | §14.3 corrected or warned | All features; Now/Next maybe skewed until corrected | Warning if detected | Get online once (captures offset) or fix clock | No |
| FA-11 | GPS unavailable/denied | Not requested in V1 | Everything | Nothing | n/a | No |
| FA-12 | Low battery | No background work by design; dark theme; no polling | Everything | Normal | n/a | No |
| FA-13 | Schedule changed | Dataset update on next open; moved/cancelled rendering | Last-known schedule meanwhile | Version stamp; changed markers | Sync | Yes (to receive) |
| FA-14 | Emergency update can't reach device | T1/T2 remain; organizer physical channels | Emergency | Provenance stamp shows data age | Sync when possible | Yes (to receive) |
| FA-15 | Browser update | Standard web compatibility; baseline targets conservative APIs | Everything | Nothing | n/a | No |
| FA-16 | Shell update mid-festival | Next-start activation; compatibility check §18.5 | Last-good dataset | "Restart to update" option | Automatic | Yes (to fetch) |
**No-blank-screen rule:** every row above ends in a defined renderable state; the static fallback page (Ring 0) covers even "shell cache missing but SW alive".
# 23. UX Architectural Implications
- **Navigation:** 4-tab bottom bar (Emergency first, visually dominant). Persistent status chip in header. No hidden gestures for critical paths.
- **States are honest and visible:** READY/PARTIAL/NOT_READY/RECOVERY/BASELINE_ONLY map 1:1 to chip colors/labels; PARTIAL enumerates; RECOVERY offers the one-tap fix.
- **Emergency UX:** giant targets, max contrast both themes, zero clutter, provenance stamp, dial buttons + copyable numbers, works one-handed.
- **Install coach:** first-class flow (iOS steps with visuals), dismissible, re-surfaces; Android uses native prompt where available.
- **Preparation flow:** explicit "Get festival data" with per-section progress; never blocks browsing what's already installed.
- **Sunlight/night:** high-contrast light theme + true dark theme (system or manual); no audio cues for anything critical.
- **Accessibility (target WCAG 2.1 AA):** landmarks/heading order per view; POIs as real buttons; Facilities list view; `prefers-reduced-motion` honored; all touch targets ≥ 48 CSS px.
- **Loading/error honesty:** no fake spinners; skeleton only where real; errors state cause + next action; retry affordances everywhere.
- **Back behavior:** in-app back on detail screens (standalone-mode safe); browser back works via history API.
# 24. Deployment Architecture (ADR-014)
```mermaid
flowchart LR
subgraph Content Repo
S1[schedule.csv/json] --> B
S2[emergency sheet] --> B
S3[poi sheet + map art] --> B
S4[info blocks] --> B
B["Pipeline: validate → build sections → hash → sign"]
end
B -->|immutable| P["/editions/<ed>/packages/<v>/…"]
B --> PTR[update latest.json]
APP[App shell build<br/>hashed assets + precache manifest<br/>+ embedded emergency baseline] -->|immutable| SH["/assets/…"]
APP --> IDX["/ index.html (no-cache)"]
P & SH & IDX & PTR --> CDN[Static HTTPS CDN<br/>single origin]
CDN --> DEV[Attendee devices]
```
| Path | Cache policy |
|---|---|
| `/` (index.html) | `no-cache` (revalidate) |
| `/assets/<hash>.*` | immutable, 1 year |
| `/latest.json` | `max-age=60` |
| `/editions/<ed>/packages/<v>/**` | immutable, 1 year |
- No dynamic backend, no database, no auth endpoint in V1.
- Publisher auth lives at the pipeline/CDN credential layer (ops detail in ADR-014).
- Environments: `staging` origin (full pipeline, test editions) → `production` origin. Identical code, different origins (storage isolation is automatic).
# 25. Browser Compatibility
| Dimension | Baseline (supported) | Best-effort | Explicitly unsupported |
|---|---|---|---|
| iOS Safari | 16.4+ installed PWA | 16.0–16.3 tab use (eviction-prone; coach pushes install) | < 16 |
| Android Chrome | ~110+ | Older with SW support | Browsers without SW |
| Other mobile browsers (Samsung Internet, Firefox Android) | Where SW+IDB exist | Same code paths | — |
| Desktop | Works via same code | Not designed for | IE, legacy engines |
| JS disabled / storage blocked | Static fallback page with emergency text (Ring 0 served by CDN/SW) | — | Full app |
Capability detection (not UA sniffing): `'serviceWorker' in navigator`, `indexedDB`, `caches`, `Intl.DateTimeFormat().resolvedOptions().timeZone`, `navigator.storage?.estimate`. Missing capability ⇒ degrade along the state machine (never crash).
# 26. Testing Implications
| Layer | What | How |
|---|---|---|
| Schema/contract | Package schema, manifest, budgets, ID stability, time sanity | Pipeline validation + fixture tests (golden packages) |
| Unit | ClockService (skew, drift, sanity), Verifier (bad sig, bad hash, replay/lower version), readiness predicate, A/B state transitions, renderer escaping | Standard unit tests |
| Integration | Full update lifecycle incl. all nine failure stages (§18.2) | Scripted harness with mocked transport + fault injection (kill mid-stage, corrupt bytes, quota errors) |
| Device matrix | iPhone (iOS 16.4 low bound + latest), low-end Android (2021 mid-range) + latest Chrome | Manual scripted scenarios: airplane mode, reboot, eviction simulation (delete site data / devtools), private mode, storage pressure, install flow, cold-start timing |
| PWA audits | Manifest/SW/offline load | Lighthouse PWA + scripted offline reload |
| Content | Emergency provenance, dataset age display, wrong-clock warnings | Scenario tests |
| Accessibility | Screen reader flows for Emergency, Schedule, Facilities list; contrast both themes | Manual + automated checks |
Rule: any bug found on a real device that the harness didn't catch becomes a harness case.
# 27. Performance Considerations
| Budget | Target |
|---|---|
| Shell JS (gz) | ≤ 150 KB |
| Shell total (gz) | ≤ 1 MB |
| Cold start → Emergency usable (2021 mid-range Android, cached) | ≤ 2 s |
| Cold start → interactive (same device) | ≤ 3 s |
| Schedule list render (1k events) | ≤ 100 ms interaction; windowed rendering if needed |
| Map pan/zoom | ≥ 30 fps on baseline devices (GPU transforms) |
| Map image decode | Overview ≤1600 px / detail ≤3072 px longest edge (total decoded ≤~35 MB); detail lazy-decoded on first zoom-in; at most one detail resident (SPIKE-03 F-1) |
| Boot verification (light) | ≤ 150 ms |
| Dataset total | ≤ 40 MB target / 50 MB ceiling |
Tactics: vanilla JS keeps parse cost trivial; images WebP; JSON parsed once per section and cached in memory per session; no timers/observers running when idle; zero background work.
# 28. Architectural Risks & Self-Critique
## 28.1 Risk register
| # | Risk | Likelihood | Impact | Mitigation / acceptance |
|---|---|---|---|---|
| RK-1 | Users don't install on iOS; tab storage evicted pre-festival | Medium | High | Install coach, prep urgency messaging, re-prep is one tap, Ring-0 floor. Residual accepted. |
| RK-2 | Attendee never prepares (no pre-festival internet) | Medium | High | Distribution campaign (ops), partial usability, physical fallback info owned by organizers. Architecture can't fix alone — flagged to product. |
| RK-3 | Map art blows size budget | Medium | Medium | Pipeline budget gates; split levels; drop detail level if needed. |
| RK-4 | IDB instability on specific iOS versions | Low-Med | High | Thin wrapper isolates; real-device spike validates early; fallback = re-prep. |
| RK-5 | Shell update breaks compatibility discipline | Low | Medium | Supported-range check at boot (§18.5), publishing ordering rule (§18.6), runbook. |
| RK-6 | Signing key loss | Low | High | Key set in shell; rotation runbook; key custody documented (ops). |
| RK-7 | Vanilla-TS hand-rolled UI accrues bugs | Medium | Medium | Small surface, strict boundaries, tests; reconsider trigger → Preact (ADR-003). |
| RK-8 | Organizer publishes breaking change right before festival | Low-Med | High | Runbook rule; schema freeze window; smoke checks. |
| RK-9 | Wrong clocks on never-online devices produce wrong Now/Next | Medium | Medium | Absolute times shown; warnings; accepted residual (§14.3). |
| RK-10 | CDN outage during festival blocks updates | Low | Low | Updates are enhancement-only; last-good datasets already on devices. |
## 28.2 Self-critique: adversarial walkthrough (16 attacks on the design)
1. **User never installs the PWA.** On iOS tab usage, storage (incl. SW registration) can be evicted after 7 days of inactivity; the app still works while open. *Weakness accepted:* pre-festival eviction is possible; mitigations: coach, prep-completeness reminders, one-tap re-prep, Ring-0 emergency floor. There is no technical fix inside the web platform — install is the fix.
2. **iOS evicts storage.** Detected at boot by failed light-check → RECOVERY state; emergency baseline intact; one-tap re-prep. *Weakness:* anything not re-delivered stays missing until online. Accepted with honest UI.
3. **SW killed mid-update.** Dataset staging doesn't run in the SW (C-21) — SW death can't corrupt staging. Page death leaves per-file staged progress; resume path defined. *No gap found.*
4. **Dataset corrupted.** Activation verification + readback spot-check + quarantine + rollback to previous slot; worst case (both slots gone) → RECOVERY + baseline. *Weakness:* corruption discovered only after previous slot GC'd and new slot later fails ⇒ re-prep needed. Accepted (rare; GC delayed by N boots).
5. **Airplane mode.** Primary design case; zero network calls on any critical path. *No gap.*
6. **Phone restarted at festival.** Cold-start budget (§27); all state durable; favorites write-through. *No gap beyond perf budget verification on real devices.*
7. **Device clock wrong.** Corrected when offset captured; sanity-window warnings; absolute times always shown. *Weakness:* never-online + wrong clock ⇒ Now/Next wrong without detection. Accepted residual.
8. **GPS unavailable.** V1 uses no GPS. *No gap.*
9. **Emergency update can't reach device.** T1 baseline + T2 last-good remain; organizer physical channels are the urgent path. *Weakness accepted and documented (A-16).*
10. **Shell and dataset incompatible.** Boot compatibility check; prefer compatible slot; else limited mode + update prompt; publishing ordering rule prevents most cases. *Weakness:* if a user updates the shell offline-only via an app-store-free push… n/a — shells also update via network, so incompatibility windows are bounded by §18.6 ordering.
11. **Browser clears storage.** Identical to eviction path (FA-5). *No additional gap.*
12. **Schedule update published immediately before festival.** Delivered on next open-while-online; runbook requires comms redundancy; dataset `generatedAt` shows staleness. *Weakness:* users who never open online keep old schedule — accepted; changed-event markers reduce confusion afterwards.
13. **User never connects after first open.** Shell + baseline function; NOT_READY state is explicit; prep screen explains exactly what's missing. *No gap; expectation management is product/ops.*
14. **Low-end Android.** Vanilla JS budget, GPU-transform map (overview ≤1600 px / detail ≤3072 px / ≤~35 MB decoded per SPIKE-03 F-1), windowed lists, WebP. *Weakness:* unverified until device-matrix testing — flagged for validation report §4 T12/T13 (YELLOW until D3 protocol passes).
15. **Restrictive iPhone settings** (block all website data, private mode, JS off). Storage-blocked ⇒ BASELINE_ONLY mode; JS off ⇒ CDN static fallback page still carries emergency text (Ring 0 also exists server-side as plain HTML). *No gap beyond the narrow fallback page.*
16. **Future mesh can't run in a browser.** Expected (analysis in §20.1). The seam is byte-transport-oriented and validation stays client-side, so a mesh would attach as an external bridge/companion implementing Transport. *If even that proves impossible, nothing in V1 is stranded:* the HTTP transport is complete product functionality, not a placeholder.
## 28.3 Known weaknesses (declared, not hidden)
- W-1: Offline-first still requires one online bootstrap; distribution is a product/ops problem architecture can only soften.
- W-2: Never-online wrong clocks cannot be detected reliably.
- W-3: Emergency *changes* cannot reach offline devices until they surface; physical redundancy required.
- W-4: Browser storage is best-effort by specification; eviction can never be fully prevented, only detected and recovered.
- W-5: Vanilla-TS UI layer has no framework safety net; discipline + tests must compensate.
- W-6: Single origin concentrates availability risk on one host/CDN (acceptable: content is static and mirrorable; future transport seam also mitigates).
# 29. Deferred Decisions
| # | Item | Why deferred | Trigger to revisit |
|---|---|---|---|
| DD-1 | Announcements feature implementation | Schema reserved; not needed for core promise | Organizer demand during build; schedule-change pressure |
| DD-2 | Push notifications | iOS fragility; pull model sufficient | Post-V1 engagement goals |
| DD-3 | Geolocation/blue dot | Not required; permission cost | Attendee demand + map maturity |
| DD-4 | Favorites export/import (QR/text) | Nice-to-have; IDs already stable | User demand |
| DD-5 | Multi-edition/multi-festival tenancy | Edition field already in schema | Second customer/event |
| DD-6 | i18n | English-only assumed (A-03) | Audience data |
| DD-7 | Mesh transport implementation | Future only (ADR-012) | Concrete transport opportunity + native companion feasibility study |
| DD-8 | On-site LAN mirror (organizer-side) | Depends on site connectivity facts (OQ-3/AMB-4) | Site survey result |
| DD-9 | Detail map level count > 2 / tiling | Budgets expected sufficient | Real map art size |
| DD-10 | SQLite/OPFS migration | IDB expected sufficient | IDB reliability spike results |
| DD-11 | Account/cross-device sync | Contradicts data-minimization posture | Explicit product decision |
# 30. Implementation Boundaries
**What exists after this phase:** three markdown documents. Nothing else.
**What the implementation phase may create (preview, non-binding):** app shell sources, SW module, store modules, sync/verifier modules, content pipeline scripts, CI config, tests — all conforming to §6 boundaries (B-1…B-7), the budgets (§10.6, §27), and the ADRs.
**Hard rules carried into implementation:**
1. No feature code imports sync/transport/IDB/SW modules directly (B-1…B-5).
2. No festival content rendered as raw HTML (C-22).
3. No runtime dependency added without an ADR amendment (target: only the audited Ed25519 verifier beyond stdlib).
4. No background processing of any kind (C-19).
5. Every state in §12.1 must be reachable in tests; no silent failure paths.
6. Budgets are CI-enforced (bundle size, package size).
7. No analytics, no third-party requests, no permissions requested.
---
*End of Architecture Design (Phase 1). Companion records: ARCHITECTURE-DECISIONS.md, ASSUMPTIONS-AND-OPEN-QUESTIONS.md.*