Lumen/ARCHITECTURE-DESIGN.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

68 KiB
Raw Permalink Blame History

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

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

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, readback flag, clock offset cache reference Rewritten atomically on activation; no per-file staging progress
lumen-slot-a, lumen-slot-b IndexedDB (stores: files, assets, staging) One complete dataset per slot: section JSON docs keyed by section id; assets as Blobs keyed by asset id; slot-local authoritative staging journal Active slot is truth; inactive slot = rollback/staging target; file/asset + journal are one transaction; 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

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: slot-local staging progress is persisted with each file/asset transaction; 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)

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.

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).

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

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 ", 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)

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.