From a7d8dbf345e7117390cfa8209f3b16d1e5535567 Mon Sep 17 00:00:00 2001 From: araharah-executor Date: Sun, 25 Oct 2026 02:44:30 +0800 Subject: [PATCH] =?UTF-8?q?docs(07):=20revise=20phase=2007=20plans=20?= =?UTF-8?q?=E2=80=94=20wire=20the=20safe-area=20watch,=20fold=20the=20corp?= =?UTF-8?q?ora=20into=20budget-audit,=20add=20the=20subrequests=20row,=20r?= =?UTF-8?q?esolve=20the=20research=20open=20questions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Checker-issue round 1 on the Phase 7 plan set: BLOCKER (07-01) — watchSafeAreaChanges and watchFullscreenEvents were created and unit-tested but never called in production: main.ts:74 was the only safe-area site and was not in files_modified, and the fullscreen events were never registered. Insets never re-resolved at runtime on a real device while every gate stayed green. - web/src/main.ts joins 07-01's files_modified; task 4 step 8 wires watchSafeAreaChanges(getWebApp()) directly after the boot site's applySafeArea(resolveSafeInsets(getWebApp())) line. - task 4 step 6 registers watchFullscreenEvents once at the guidance-start edge in route.ts, with a deliberately no-op callback (events are observability, never correctness). - New grep acceptance criteria pin BOTH production call sites; two new must_haves key_links name them; T-07-06b now names the boot-site call. WARNING (07-04 / VALIDATION.md) — the golden corpora were attributed to 07-04 t3, which never ran them. Folded into the budget audit: runGoldenCorpusGate() re-runs `npm run routing:corpus` and `npm run search:corpus` as child processes (the same shape runCpuLadderGate already uses), so the ONE budget-audit command enforces SC4.a/SC4.b. VALIDATION.md rows now name 07-04 t2 and the budget-audit command. WARNING (07-02 t3) — the landscape-leg snippet omitted the required setup. The action now says to copy the existing corridor guidance leg VERBATIM (setup stubGuidanceRoute(page, corridorModel) + guideQuery carrying dest + fixpair + waitMode/expectGuide) and change ONLY the name, the scheme, and the viewport; two grep criteria pin the setup and the query. WARNING (07-04) — D-06/SC4 name subrequests but nothing measured beside the 50/request ceiling. Added runSubrequestClassGate(): a per-request-class inventory derived from the Worker's route table (engine-A /api/route = 0 external subrequests; the cron tick's Telegram send is the only recurring class; KV ops tracked separately against their own ceiling). Unmeasurable counts read "not measured", never 0. docs/BUDGETS.md gains a "## Subrequests per request class" section and the record states that the inventory is source-derived, not a live Workers Logs metric. WARNING (07-RESEARCH.md) — "## Open Questions" is now "## Open Questions (RESOLVED)" and each of the five items carries its adopted disposition; Q2/Q3 resolve to "answered by measurement at the launch gate per D-04/D-06". INFO (07-VALIDATION.md) — nyquist_compliant flipped to true and the sign-off is approved 2026-10-25 after the map-row correction. Waves, dependencies, autonomous flags, and the other three plans are unchanged. --- .planning/ROADMAP.md | 40 +- .../07-hardening-private-launch/07-01-PLAN.md | 111 ++- .../07-hardening-private-launch/07-02-PLAN.md | 71 +- .../07-hardening-private-launch/07-04-PLAN.md | 133 +++- .../07-RESEARCH.md | 748 ++++++++++++++++++ .../07-VALIDATION.md | 23 +- 6 files changed, 1048 insertions(+), 78 deletions(-) create mode 100644 .planning/phases/07-hardening-private-launch/07-RESEARCH.md diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index f8c2fef..773c9c9 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -26,8 +26,8 @@ Decimal phases appear between their surrounding integers in numeric order. - [x] **Phase 3: Mini App Shell + Geolocation** - Telegram WebApp shell that boots, authenticates, and reads a reliable live location across the Android/iOS/Desktop client matrix (completed 2026-10-23) - [x] **Phase 4: Destination Search + KL Gazetteer** - Free-text KL search that resolves landmarks despite Malay abbreviations and typos, venue cards, and Mini App launch with destination preloaded (completed 2026-10-23) - [x] **Phase 5: Routing + Route Display** - Valhalla-backed route calculation shown as a Rams route line with quiet alternative cards; the full search→route→render loop lands (completed 2026-10-23) -- [ ] **Phase 6: Turn-by-Turn Guidance (Live Driving)** - Client guidance state machine: maneuver banner, missed-turn auto-recalculation, honest ETA, haptics, seamless resume, and backgrounded chat turn-cards -- [ ] **Phase 7: Hardening + Private Launch** - Device-matrix QA, graceful failure states, privacy-by-construction audit, and golden-route regression before real drivers +- [x] **Phase 6: Turn-by-Turn Guidance (Live Driving)** - Client guidance state machine: maneuver banner, missed-turn auto-recalculation, honest ETA, haptics, seamless resume, and backgrounded chat turn-cards (completed 2026-10-24) +- [ ] **Phase 7: Hardening + Private Launch** - Device-matrix QA, graceful failure states, privacy-by-construction audit, and golden-route regression before real drivers (planned 2026-10-25) ## Phase Details @@ -198,20 +198,26 @@ Notes: Plans: **Wave 1** *(parallel foundations — the pure cores + the envelope going live)*: -- [ ] 06-01-PLAN.md — Engine A pure core: pinned CSR binary codec (with street-name table), GPS-fix snapping, typed-array A* + step/turn derivation, proven against a committed mini-graph by A*≡Dijkstra equivalence (D-01/D-05, GUID-02) -- [ ] 06-02-PLAN.md — Guidance pure core: the D-04 reroute state machine with named constants, D-07 EMA+deadband ETA, D-15 distance throttle, four committed D-16 drive fixtures + replay/eta suites (GUID-02/03/04/06) -- [ ] 06-03-PLAN.md — The frozen envelope goes LIVE: mint/get/delete TTL-only service + Bearer-gated /api/guidance POST/GET/DELETE (claim-only chat), GE4–GE7 re-pinned in the same change (GUID-05, D-14 server half) +- [x] 06-01-PLAN.md — Engine A pure core: pinned CSR binary codec (with street-name table), GPS-fix snapping, typed-array A* + step/turn derivation, proven against a committed mini-graph by A*≡Dijkstra equivalence (D-01/D-05, GUID-02) +- [x] 06-02-PLAN.md — Guidance pure core: the D-04 reroute state machine with named constants, D-07 EMA+deadband ETA, D-15 distance throttle, four committed D-16 drive fixtures + replay/eta suites (GUID-02/03/04/06) +- [x] 06-03-PLAN.md — The frozen envelope goes LIVE: mint/get/delete TTL-only service + Bearer-gated /api/guidance POST/GET/DELETE (claim-only chat), GE4–GE7 re-pinned in the same change (GUID-05, D-14 server half) **Wave 2** *(blocked on Wave 1 — the real graph + the cockpit chrome, in parallel)*: -- [ ] 06-04-PLAN.md — Engine A server surface: Overpass→CSR build tool + real KL graph, R2 bucket/upload/fork docs, isolate-memoized worker-router behind Bearer-gated /api/route, client http-source backend #2, RP1/RP2 re-pins, A*-CPU measurement gate (D-01/D-03, GUID-02) -- [ ] 06-05-PLAN.md — Guidance chrome per 06-UI-SPEC: committed 8-glyph set, banner/list/guidance-card/confirm/trip-end builders, close-protection + haptics capability guards, strings keys en+ms, shell.css guidance rules, guidance-surface suite + RS10/BA9 bumps (GUID-01/04, PX-03) +- [x] 06-04-PLAN.md — Engine A server surface: Overpass→CSR build tool + real KL graph, R2 bucket/upload/fork docs, isolate-memoized worker-router behind Bearer-gated /api/route, client http-source backend #2, RP1/RP2 re-pins, A*-CPU measurement gate (D-01/D-03, GUID-02) +- [x] 06-05-PLAN.md — Guidance chrome per 06-UI-SPEC: committed 8-glyph set, banner/list/guidance-card/confirm/trip-end builders, close-protection + haptics capability guards, strings keys en+ms, shell.css guidance rules, guidance-surface suite + RS10/BA9 bumps (GUID-01/04, PX-03) **Wave 3** *(blocked on Wave 2 — integration)*: -- [ ] 06-06-PLAN.md — Guidance integration: D-10 locked-follow camera, the fix pump (banner/ETA/haptics/reroute), PX-01 full BackButton state graph, minimize-POST/resume-GET/trip-end-DELETE envelope flows, RS6 re-pin (GUID-02/05/06, PX-01/03, D-14 web half) -- [ ] 06-07-PLAN.md — Cron turn-cards: pure projectTurnCards + the repo's first scheduled() handler + triggers.crons, honest street-less caption copy, KV-write-budget pin, committed 8-glyph PNG set via sharp (GUID-05, D-11/12/13) +- [x] 06-06-PLAN.md — Guidance integration: D-10 locked-follow camera, the fix pump (banner/ETA/haptics/reroute), PX-01 full BackButton state graph, minimize-POST/resume-GET/trip-end-DELETE envelope flows, RS6 re-pin (GUID-02/05/06, PX-01/03, D-14 web half) +- [x] 06-07-PLAN.md — Cron turn-cards: pure projectTurnCards + the repo's first scheduled() handler + triggers.crons, street-bearing cards by default via graph reconstruction (street-less only as graph-absent degradation per D-03; D-12 with 06-08 Part C ratification), KV-write-budget pin, committed 8-glyph PNG set via sharp (GUID-05, D-11/12/13) **Wave 4** *(the phase's closing gates)*: -- [ ] 06-08-PLAN.md — Verification close: ?drive= replay knob + seven guidance capture legs + DSGN-02 gallery, then the ONE REAL DRIVE device gate (blocking checkpoint) (D-16, all reqs) +- [x] 06-08-PLAN.md — Verification close: ?drive= replay knob + seven guidance capture legs + DSGN-02 gallery, then the ONE REAL DRIVE device gate (blocking checkpoint) (D-16, all reqs) + +**Cross-cutting constraints** *(shared invariants enforced in multiple plans; no verbatim-duplicated truths)*: +- **Claim-only chat identity (T-06-02):** envelope chat_id derives from the Bearer claim server-side — never a request body — in 06-03 (mint/read/delete), 06-06 (POST composition), 06-07 (cron push target) +- **Fail-honest degradation (D-03):** absent/tampered graph → null → router-pending (06-01 decode, 06-04 `/api/route`, 06-07 street reconstruction); never a throw, never a wrong answer +- **TTL-only KV (GE1):** every envelope write carries `expirationTtl: 7200`; active DELETE on trip end (06-03 service, 06-06 flows, 06-07 zero-write cadence) +- **Privacy logging:** category-only logs, never coordinates/chat ids in output (06-03, 06-06, 06-07, 06-08 capture assertions) Notes: - Highest behavioral uncertainty (reroute triggers, ETA smoothing, battery) — research recommends `/gsd-research-phase` (backgrounded turn-card "best-effort projection" framing + static map snapshot generation; product decision needed for what "good enough" means minimized). @@ -227,10 +233,20 @@ Notes: 2. Every empty/error/timeout/off-road/no-geometry state renders gracefully (no blank screens, no broken layouts); version-gating fallbacks behave sanely on older Telegram clients 3. Privacy verification passes: an architecture audit shows no persistent per-user location history or profile anywhere — coordinates exist only in TTL-expiring KV, and logs carry no coordinate data (re-verifies PRIV-02, GEO-03 end-to-end) 4. The golden-route regression pass is green, Worker budgets audit clean (subrequests/CPU, cache hit-rate), and the "known limitations" doc (OSM data gaps, no traffic confidence, no lane guidance) ships before the first real drivers -**Plans**: TBD +**Plans**: 6 plans + +Plans: +- [ ] 07-01-PLAN.md — Fullscreen capability layer: the Telegram 8.0-guarded `requestFullscreen()` wrapper, the grown `TelegramWebAppLike` contract, the safe-area re-resolution on inset events (WIRED at the `web/src/main.ts` boot site), the route controller's guidance-start/trip-end wiring + the fullscreen-event watcher, and the FS1–FS9 suite (PX-04, D-03) +- [ ] 07-02-PLAN.md — Landscape geometry as CSS-only media queries (tokens binding), the FM1–FM7 failure-state matrix (empty/error/timeout/no-geometry/off-road/old-client/location-timeout), and the landscape capture legs (PX-04, SC2) +- [ ] 07-03-PLAN.md — The consolidated `npm run privacy-audit` tool: source gates over ALL src trees, the KV TTL gate, the category-only log gate, the positive fetch-host allowlist, the production-bundle scan, the live KV expiry probe, and the signed audit record (D-01, D-02) +- [ ] 07-04-PLAN.md — The consolidated `npm run budget-audit` tool: the re-run A* CPU ladder, the bundle-byte budgets, the KV write classes with the cron's zero-write law, the per-request-class subrequest inventory, the re-run golden corpora, the static-asset `_headers` policy, and the checked-in current-vs-free-tier budget record (D-06) +- [ ] 07-05-PLAN.md — The parked debts paid: IN-01's dead export + intent comments, IN-02's stale wrangler comment, IN-03's exact-900s boundary pin, the `/cari` re-arm attempt, the A1/A3 closure — plus `docs/KNOWN-LIMITATIONS.md` and the `/help` pointer line (D-07, D-08) +- [ ] 07-06-PLAN.md — The launch gate: the consolidated 4-client × 7-interaction walk, the recorded Android battery measurement, the six-item D-09 checklist, and the milestone completion record (D-04, D-05, D-09) Notes: - No exclusive feature requirements beyond PX-04 — this phase hardens the outcomes of Phases 1–6 and is the last chance to catch the "looks done but isn't" failures for a navigation tool whose empty states ARE the polish surface. +- **Wave structure**: Wave 1 = 07-01, 07-02, 07-03, 07-05 (four parallel plans, no file overlap) · Wave 2 = 07-04 (shares `package.json` with 07-03 and imports 07-03's live-probe library) · Wave 3 = 07-06 (the blocking launch gate — depends on all five). +- The two manual behaviors (the physical device walk and the battery run) live in the 07-06 blocking LAUNCH-GATE checkpoint — never synthesized. NOT-RUN legs are recorded honestly and keep the checklist unsigned. **UI hint**: no ## Requirements Coverage @@ -264,7 +280,7 @@ Phases execute in numeric order: 1 → 2 → 3 → 4 → 5 → 6 → 7 | 4. Destination Search + KL Gazetteer | 0/4 | Ready to execute | - | | 5. Routing + Route Display | 0/TBD | Not started | - | | 6. Turn-by-Turn Guidance (Live Driving) | 0/TBD | Not started | - | -| 7. Hardening + Private Launch | 0/TBD | Not started | - | +| 7. Hardening + Private Launch | 0/6 | Ready to execute | - | --- *Roadmap created: 2026-10-22* diff --git a/.planning/phases/07-hardening-private-launch/07-01-PLAN.md b/.planning/phases/07-hardening-private-launch/07-01-PLAN.md index 7b9a967..888987d 100644 --- a/.planning/phases/07-hardening-private-launch/07-01-PLAN.md +++ b/.planning/phases/07-hardening-private-launch/07-01-PLAN.md @@ -10,6 +10,7 @@ files_modified: - web/src/telegram/safe-area.ts - web/src/mock/telegram-mock.ts - web/src/route/route.ts + - web/src/main.ts - web/test/fullscreen.test.mjs autonomous: true requirements: @@ -28,8 +29,11 @@ must_haves: provides: "TelegramWebAppLike contract grown with the 8.0-optional fullscreen/orientation members" contains: "requestFullscreen?" - path: "web/src/telegram/safe-area.ts" - provides: "Re-resolution on safeAreaChanged / contentSafeAreaChanged (applySafeAreaOnChange)" + provides: "Re-resolution on safeAreaChanged / contentSafeAreaChanged (watchSafeAreaChanges — the exported name, never a second alias)" contains: "safeAreaChanged" + - path: "web/src/main.ts" + provides: "The boot-site call site for watchSafeAreaChanges — the insets re-resolve at runtime, not only once at boot" + contains: "watchSafeAreaChanges" - path: "web/test/fullscreen.test.mjs" provides: "FS1..FS9 + old-client fallback vectors (PX-04.a-d, SC2.e)" contains: "FS1" @@ -50,6 +54,14 @@ must_haves: to: "web/src/telegram/webapp.ts" via: "the mock satisfies every new optional member (contract + fake + test together law)" pattern: "requestFullscreen" + - from: "web/src/main.ts" + to: "web/src/telegram/safe-area.ts" + via: "watchSafeAreaChanges(deps.wa ?? null) is CALLED at the boot site, directly after applySafeArea(resolveSafeInsets(getWebApp())) — the insets re-resolve at runtime, not only once at boot" + pattern: "watchSafeAreaChanges" + - from: "web/src/route/route.ts" + to: "web/src/telegram/fullscreen.ts" + via: "watchFullscreenEvents(deps.wa ?? null, …) is REGISTERED at the guidance-start edge (the same edge as engageCloseProtection) — the events are observability, the callback is a no-op-safe category handler" + pattern: "watchFullscreenEvents" --- @@ -64,7 +76,8 @@ guard, orientation lock only where available). The telemetry-Rams tokens stay binding; this plan adds no art direction and no new UI surface. Output: `web/src/telegram/fullscreen.ts` (new), the grown `TelegramWebAppLike` contract, the re-resolving safe-area module, the mock's matching members, the -route controller's guidance-start/trip-end wiring, and +route controller's guidance-start/trip-end wiring, the `web/src/main.ts` boot +wiring for the safe-area watch, and `web/test/fullscreen.test.mjs` (new — PX-04.a–d + the old-client fallback). @@ -161,6 +174,28 @@ Pitfall 1): // … } ``` + +From `web/src/main.ts` (the BOOT site — the ONLY place the safe-area insets +are resolved today; `watchSafeAreaChanges` must be called immediately after +this line, or the insets never re-resolve on a real device): + +```typescript +async function boot(): Promise { + // 1 — safe-area vars are set before any layout reads them (PX-03 groundwork). + applySafeArea(resolveSafeInsets(getWebApp())); + // ← watchSafeAreaChanges(wa ?? null) belongs HERE (task 4, step 5) + // … + const wa = getWebApp(); + // … +} +``` + +NOTE the ORDER: `applySafeArea(resolveSafeInsets(getWebApp()))` is the FIRST +statement of `boot()` and `const wa = getWebApp();` appears LATER in the +same function. The watch call MUST use the same one-shot `getWebApp()` value +as the apply call (`getWebApp()` is idempotent — it returns the same WebApp +instance or null), so it does NOT introduce a second `getWebApp()` call and +does NOT depend on the later `const wa` binding's position. @@ -418,18 +453,20 @@ await import("../src/telegram/fullscreen.ts");`). - task 4: wire the fullscreen call into the guidance-start and trip-end edges - web/src/route/route.ts + task 4: wire the fullscreen call into the guidance-start and trip-end edges + the safe-area watch at the boot site + web/src/route/route.ts, web/src/main.ts - web/src/route/route.ts (the activateGuidance / teardownGuidance edges — lines ~337 and ~474) + - web/src/main.ts (the boot site — the `applySafeArea(resolveSafeInsets(getWebApp()))` line at the TOP of `boot()`, around line 74) - web/src/telegram/fullscreen.ts (the wrapper, as built in task 1) + - web/src/telegram/safe-area.ts (the watch, as built in task 2) - .planning/phases/07-hardening-private-launch/07-RESEARCH.md §Pitfall 1 (the activation window) In `web/src/route/route.ts`: 1. Add the import next to the existing `close-protection.ts` import: - `import { requestGuidanceFullscreen, exitGuidanceFullscreen } from "../telegram/fullscreen.ts";` + `import { requestGuidanceFullscreen, exitGuidanceFullscreen, watchFullscreenEvents } from "../telegram/fullscreen.ts";` 2. In `activateGuidance()` — the SYNCHRONOUS guidance-start transition, the same edge that already calls `engageCloseProtection(deps.wa ?? null);` — @@ -456,29 +493,77 @@ In `web/src/route/route.ts`: `releaseCloseProtection(deps.wa ?? null);` — add ONE line directly after it: `exitGuidanceFullscreen(deps.wa ?? null);` -6. Add a one-line comment above each new call naming D-03 + PX-04 and the +6. **REGISTER the fullscreen EVENT watcher at the guidance-start edge.** In + `activateGuidance()` — the SAME edge as step 2 — add ONE line directly + after the `requestGuidanceFullscreen(...)` line: + `watchFullscreenEvents(deps.wa ?? null, () => { /* observability only — no state, no logging */ });` + The callback is a deliberate NO-OP: per RESEARCH Open Question 1 the + fullscreen events are OBSERVABILITY, never correctness — the UI must be + fully usable without receiving either event. The registration MUST NOT + log anything (the D-14 category-only law), MUST NOT change any state, and + MUST NOT write any storage surface. `watchFullscreenEvents` is internally + guarded (null WebApp / missing `onEvent` → returns immediately), so this + call is safe in a plain browser and on an old client. + Do NOT register the watcher in `resume(trip)` — `activateGuidance()` is the + single guidance-start edge and the watcher must be registered exactly + once per guidance session. + +7. Add a one-line comment above each new call naming D-03 + PX-04 and the best-effort law, in the file's existing voice (e.g. `// PX-04/D-03: the native fullscreen request is BEST EFFORT — a refusal is a supported fallback, never a guidance failure.`). +In `web/src/main.ts`: + +8. **WIRE the safe-area watch at the BOOT site.** The ONLY place the insets + are resolved today is the FIRST statement of `boot()`: + `applySafeArea(resolveSafeInsets(getWebApp()));` + Add ONE line DIRECTLY after it (before the `// 3 (declared ahead…)` comment + block): + `watchSafeAreaChanges(getWebApp());` + and extend the existing import to include it: + `import { resolveSafeInsets, applySafeArea, watchSafeAreaChanges } from "./telegram/safe-area.ts";` + + WHY THIS SITE: without this call the insets are resolved ONCE at boot and + never again — a device rotation or a fullscreen transition would leave the + cockpit under a notch or a Telegram control while every gate stays green. + `watchSafeAreaChanges` re-resolves through the SAME union authority + (`resolveSafeInsets`) on `safeAreaChanged` / `contentSafeAreaChanged`, so + the runtime value and the boot value can never disagree. + + Use `getWebApp()` — the SAME one-shot call the adjacent apply line uses — + rather than the later `const wa = getWebApp();` binding (which is declared + further down in `boot()` and would be a forward reference at this point). + `getWebApp()` is idempotent, so this adds no second client lookup. + `watchSafeAreaChanges` is internally guarded (null WebApp / missing + `onEvent` → returns immediately), so the plain-browser boot path is + unaffected. + Do NOT add a new console line, do NOT log the fullscreen state, and do NOT add any coordinate-bearing output — the route module's console law (`ARAHARAH-ROUTE state=…` / `ARAHARAH-GUIDE state=…` state tokens only) is unchanged. - - `grep -c "requestGuidanceFullscreen" web/src/route/route.ts` returns exactly 2 (activateGuidance + resume) - - `grep -c "exitGuidanceFullscreen" web/src/route/route.ts` returns exactly 2 (teardownGuidance + stopGuidanceArtifacts) + - `grep -c "requestGuidanceFullscreen(" web/src/route/route.ts` returns exactly 2 (activateGuidance + resume — the CALL sites; the import line does not match because it carries no `(`) + - `grep -c "exitGuidanceFullscreen(" web/src/route/route.ts` returns exactly 2 (teardownGuidance + stopGuidanceArtifacts) + - `grep -c "watchFullscreenEvents(" web/src/route/route.ts` returns exactly 1 (registered ONCE, at the guidance-start edge) - `grep -c "engageCloseProtection" web/src/route/route.ts` returns 2 (unchanged — the count did not move) - `grep -c "releaseCloseProtection" web/src/route/route.ts` returns 2 (unchanged) + - `grep -c "watchSafeAreaChanges(" web/src/main.ts` returns exactly 1 (the boot-site CALL site) + - `grep -c "watchSafeAreaChanges" web/src/telegram/safe-area.ts` returns ≥ 1 (the export still exists) + - `grep -c "watchFullscreenEvents" web/src/telegram/fullscreen.ts` returns ≥ 1 (the export still exists) - `npm run web:typecheck` exits 0 - export PATH="$HOME/.nvm/versions/node/v24.18.0/bin:$PATH"; cd /home/C-Fu/araharah && npm test -w web 2>&1 | tail -15 && npm run web:typecheck 2>&1 | tail -10 + export PATH="$HOME/.nvm/versions/node/v24.18.0/bin:$PATH"; cd /home/C-Fu/araharah && npm test -w web 2>&1 | tail -15 && npm run web:typecheck 2>&1 | tail -10 && echo "--- production call sites ---" && grep -c "watchSafeAreaChanges" web/src/main.ts && grep -c "watchFullscreenEvents" web/src/route/route.ts Fullscreen is requested at guidance start (fresh and resumed) and released at trip end and walk-back, on the synchronous edges, best-effort, with no - new logging. + new logging; the fullscreen event watcher is registered exactly once at + the guidance-start edge; and the safe-area watch is CALLED at the boot + site in `web/src/main.ts` so the insets re-resolve on rotation and + fullscreen at runtime. @@ -497,7 +582,7 @@ unchanged. | Threat ID | Category | Component | Disposition | Mitigation Plan | |-----------|----------|-----------|-------------|-----------------| | T-07-06 | Denial of Service | `web/src/telegram/fullscreen.ts` — a throwing/refused platform call | mitigate | The capability guard is BOTH halves (method presence AND `isVersionAtLeast("8.0")`); every platform call is wrapped in try/catch; `requestGuidanceFullscreen` returns a boolean the caller IGNORES, so a refusal can never fail the guidance start. Pinned by FS1–FS5 and the FS9 source-scan ban. | -| T-07-06b | Denial of Service | `web/src/telegram/safe-area.ts` — a throwing inset-event handler | mitigate | `watchSafeAreaChanges` wraps each handler in try/catch; a client that throws on `safeAreaChanged` cannot break the boot or the guidance pump. Pinned by FS6/FS8. | +| T-07-06b | Denial of Service | `web/src/telegram/safe-area.ts` — a throwing inset-event handler | mitigate | `watchSafeAreaChanges` wraps each handler in try/catch; a client that throws on `safeAreaChanged` cannot break the boot or the guidance pump. Pinned by FS6/FS8. The watch is ALSO CALLED at the production boot site in `web/src/main.ts` (grep: `watchSafeAreaChanges` in `web/src/main.ts` ≥ 1), so the insets re-resolve at runtime — an exported-but-uncalled watch would leave the cockpit under a notch after a rotation while every gate stayed green. | | T-07-09 | Information Disclosure | the fullscreen event path | mitigate | The module logs NOTHING (`grep -c "console\."` = 0); no coordinate, user id, or route payload is produced or emitted. `fullscreenFailed` is a category event, never a user-facing error. | | T-07-10 | Tampering | the DOM-fullscreen / generic-orientation-lock escape hatches | mitigate | The FS9 source-scan ban forbids `documentElement.requestFullscreen`, `screen.orientation.lock`, `webkitRequestFullscreen`, `mozRequestFullScreen` in the module's code — the Telegram-native path is the ONLY primary path. | | T-07-11 | Information Disclosure | `web/src/telegram/webapp.ts` contract growth | accept | The contract gains OPTIONAL members only; no existing member's shape changes, so no existing call site can be broken by the growth. Low-value target, no data path. | @@ -520,8 +605,8 @@ must all appear as `PASS`. - `web/src/telegram/fullscreen.ts` exists with the both-halves 8.0 guard, try/catch on every call, no DOM-fullscreen, no generic orientation lock, and zero logging. - `TelegramWebAppLike` carries the five 8.0-optional members; the harness mock satisfies them in the same change. -- `watchSafeAreaChanges` re-resolves the insets on `safeAreaChanged` / `contentSafeAreaChanged` through the existing union authority. -- `web/src/route/route.ts` requests fullscreen at the guidance start (fresh + resumed) and releases it at trip end + walk-back — exactly 2 request sites and 2 exit sites, both on the synchronous edges. +- `watchSafeAreaChanges` re-resolves the insets on `safeAreaChanged` / `contentSafeAreaChanged` through the existing union authority, AND it is CALLED at the boot site in `web/src/main.ts` (directly after the `applySafeArea(resolveSafeInsets(getWebApp()))` line) — the insets re-resolve at runtime, not only once at boot. +- `web/src/route/route.ts` requests fullscreen at the guidance start (fresh + resumed) and releases it at trip end + walk-back — exactly 2 request sites and 2 exit sites, both on the synchronous edges — and registers `watchFullscreenEvents` exactly once at the guidance-start edge. - `web/test/fullscreen.test.mjs` carries FS1–FS9 and the whole `web` suite is green. diff --git a/.planning/phases/07-hardening-private-launch/07-02-PLAN.md b/.planning/phases/07-hardening-private-launch/07-02-PLAN.md index e5b6dff..f215c29 100644 --- a/.planning/phases/07-hardening-private-launch/07-02-PLAN.md +++ b/.planning/phases/07-hardening-private-launch/07-02-PLAN.md @@ -370,29 +370,56 @@ Extend `web/scripts/capture.mjs`: 2. Add ONE new landscape leg after the existing guidance legs, using the `?drive=corridor-follow` replay knob (the committed fixture the 06-08 - guidance legs already use) and the guidance wait mode: + guidance legs already use) and the guidance wait mode. -```javascript - /* ————— 07-02 — the landscape leg (PX-04.e) ————— - The driving posture: the phone rotated. The cockpit geometry adapts via - the @media (orientation: landscape) block (07-02 task 1); the map stays - Rams-constant. A 844x390 viewport IS the landscape orientation the media - query matches (orientation: landscape + max-height: 560px). */ - await captureLeg({ - name: 'shell-guidance-landscape.png', - query: 'mock=android&scheme=light&drive=corridor-follow', - colorScheme: 'light', - grantGeo: true, - waitMode: 'guide', - expectGuide: 'on_route', - viewport: { width: 844, height: 390 }, - }); -``` + **COPY THE EXISTING CORRIDOR GUIDANCE LEG VERBATIM.** The new leg is the + existing corridor guidance leg (the one that uses `corridorModel` + + `guideQuery` — e.g. `shell-guidance-active.png`) with ONLY THREE things + changed: the `name`, the `colorScheme`, and the `viewport`. Keep + EVERYTHING else byte-identical — in particular: - Match the exact `expectGuide` value the existing guidance legs use for the - corridor drive — READ the existing guidance legs in this file and copy - their `expectGuide`/`waitMode` pair verbatim rather than guessing. If the - existing corridor leg uses `waitMode: 'settle400'` instead, use that. + - the `setup:` — `setup: (page) => stubGuidanceRoute(page, corridorModel)` — + MUST be copied verbatim. WITHOUT this setup the `/api/route` stub is + never installed, the route never resolves, and the leg TIMES OUT at the + guide-state race with an `ARAHARAH-GUIDE state=… race timed out` error. + - the `query:` — `guideQuery` MUST be copied verbatim. `guideQuery` is + `mock=android&dest=${guidePair.destinationId}&fixpair=${guidePair.pairId}&drive=corridor-follow` + — it carries BOTH the `dest` (the destination id the route resolves to) + AND the `fixpair` (the fixture pair id the replay stream keys on). + Dropping `dest` gives the leg no destination; dropping `fixpair` gives + it no replay fixes. Both are required. + - the `waitMode:` / `expectGuide:` pair — copy the existing corridor leg's + pair verbatim (`waitMode: 'guide'`, `expectGuide: 'on_route'` — READ the + file and confirm; if the existing leg uses `waitMode: 'settle400'` + instead, use that). + + So the leg is: + + ```javascript + /* ————— 07-02 — the landscape leg (PX-04.e) ————— + The driving posture: the phone rotated. The cockpit geometry adapts via + the @media (orientation: landscape) block (07-02 task 1); the map stays + Rams-constant. A 844x390 viewport IS the landscape orientation the media + query matches (orientation: landscape + max-height: 560px). + The setup + query + waitMode/expectGuide are copied VERBATIM from the + existing corridor guidance leg — only the name, the scheme, and the + viewport differ. */ + await captureLeg({ + name: 'shell-guidance-landscape.png', + setup: (page) => stubGuidanceRoute(page, corridorModel), + query: guideQuery, + colorScheme: 'light', + grantGeo: true, + waitMode: 'guide', + expectGuide: 'on_route', + viewport: { width: 844, height: 390 }, + }); + ``` + + The `expectText` array and the `interact:` block are OPTIONAL — copy them + from the source leg if you want the same DOM assertions, but the leg MUST + at minimum carry the `setup`, the `query` (with `dest` + `fixpair`), and + the `waitMode`/`expectGuide` pair. 3. Add ONE failure-state leg that photographs the designed pending card (the empty/error route class) so the surface is on film, not only in a Node @@ -433,6 +460,8 @@ Extend `web/scripts/capture.mjs`: - `grep -c "shell-guidance-landscape.png" web/scripts/capture.mjs` returns ≥ 1 - `grep -c "shell-route-pending-landscape.png" web/scripts/capture.mjs` returns ≥ 1 - `grep -c "width: 844, height: 390" web/scripts/capture.mjs` returns ≥ 2 (both new legs) + - `grep -c "stubGuidanceRoute(page, corridorModel)" web/scripts/capture.mjs` returns ≥ 6 (the five existing corridor legs + the new landscape leg — the landscape leg MUST carry the route stub setup) + - `grep -c "query: guideQuery" web/scripts/capture.mjs` returns ≥ 5 (the four existing corridor legs + the new landscape leg — the landscape leg MUST carry the `dest` + `fixpair` query) - `npm run capture -w web` exits 0 and the report lists BOTH new landscape shots as non-blank diff --git a/.planning/phases/07-hardening-private-launch/07-04-PLAN.md b/.planning/phases/07-hardening-private-launch/07-04-PLAN.md index 00d80ac..3abf838 100644 --- a/.planning/phases/07-hardening-private-launch/07-04-PLAN.md +++ b/.planning/phases/07-hardening-private-launch/07-04-PLAN.md @@ -21,6 +21,8 @@ must_haves: - "The A* CPU cost over the real KL graph is MEASURED and PRINTED against the 8 ms budget — never assumed, never hoped for." - "The shipped route payload's gzipped size is measured and printed against the 200 KiB (204800 byte) budget." - "Every KV write class is recorded with its TTL — and the cron tick is proven to write ZERO times." + - "The 50-subrequests-per-request ceiling has a per-request-class INVENTORY beside it — every class's external subrequest count is derived from the source, and a count that cannot be derived reads `not measured`, never `0`." + - "The golden route and search corpora are re-run by the SAME `npm run budget-audit` command — so the budget record and the corpora can never disagree." - "Static assets carry an explicit cache policy: hashed /assets/* are immutable for a year, the HTML entry revalidates." - "The budget record compares every current number with the documented Cloudflare free-plan ceiling in ONE place." artifacts: @@ -28,7 +30,7 @@ must_haves: provides: "The consolidated budget CLI (npm run budget-audit) — measurement + free-tier comparison + exit codes" contains: "BUDGET AUDIT" - path: "scripts/lib/budget-gates.mjs" - provides: "The measurement gates — CPU ladder, bundle bytes, KV write classes, cache headers" + provides: "The measurement gates — CPU ladder, bundle bytes, KV write classes, cache headers, subrequest classes, golden corpora" contains: "P95_BUDGET_MS" - path: "web/public/_headers" provides: "The Workers static-assets cache policy for hashed assets and the HTML entry" @@ -45,6 +47,14 @@ must_haves: to: "bot/src/webhook.ts" via: "the cron tick's zero-write law is proven by scanning scheduled() for .put( — the source half of SC4.e" pattern: "cron" + - from: "scripts/lib/budget-gates.mjs" + to: "package.json" + via: "the golden corpora (SC4.a/SC4.b) are RE-RUN as child processes — `npm run routing:corpus` and `npm run search:corpus` — so the ONE budget-audit command also enforces them" + pattern: "routing:corpus" + - from: "scripts/lib/budget-gates.mjs" + to: "bot/src/webhook.ts" + via: "the per-request-class subrequest inventory is derived from the Worker's route table — /api/route = 0 external subrequests; the cron tick's Telegram send is the only recurring subrequest class" + pattern: "subrequest" - from: "web/public/_headers" to: "web/dist/assets" via: "the /assets/* rule covers Vite's hashed output; the live probe reads the deployed response headers back" @@ -241,7 +251,9 @@ It MUST export: export function runCpuLadderGate(); // id: "route-cpu" — RE-RUNS the existing script export function runBundleBudgetGate(); // id: "bundle-budget" — BA9's gzip measurement export function runKvWriteClassGate(); // id: "kv-writes" — write classes + the cron's zero-write law +export function runSubrequestClassGate(); // id: "subrequests" — the per-request-class subrequest inventory export function runCacheHeaderGate(); // id: "cache-headers" — live headers (or NOT RUN) +export function runGoldenCorpusGate(); // id: "golden-corpus" — RE-RUNS the routing + search golden corpora ``` **`runCpuLadderGate()`** — DO NOT re-implement the A* measurement. Spawn the @@ -289,6 +301,50 @@ the TTL constant referenced. Assert every write references a NAMED constant the constants out of the three `bot/src/kv/*.ts` files with a regex on `export const = ;` — never hard-code the numbers. +**`runSubrequestClassGate()`** — the SUBREQUEST inventory (D-06 names +"Worker CPU/subrequests" — the 50-subrequests-per-request free-plan ceiling +must have a number beside it, not just the ceiling). The gate is a +SOURCE-DERIVED per-request-class inventory, and it must say plainly which +numbers are measured and which are not: + +- Enumerate the Worker request classes from `bot/src/webhook.ts`'s route table + (read the file; the classes are the request paths the Worker answers — e.g. + the session mint, the location pin, the guidance envelope, the route + endpoint, the Telegram webhook). +- For EACH class, count the EXTERNAL subrequests the code path can make. + Two facts are derivable from the source and MUST be recorded: + - the route endpoint (`/api/route`) makes **0 external subrequests** — the + routing graph is loaded from the bundled KV binding, not fetched, and the + A* search is pure CPU; + - the cron tick's Telegram send is the ONLY recurring external-subrequest + class — one `fetch` to the Telegram Bot API per due guidance envelope. + KV operations are NOT subrequests — Cloudflare counts them against the + separate KV-operations ceiling (1,000/invocation) — so KV ops are + reported in the KV-write gate, and this gate says so explicitly rather + than silently omitting them. +- Where a class's subrequest count CANNOT be derived from the source (for + example a count that depends on the number of live envelopes), print + `NOT MEASURED` with the reason — never a zero and never an estimate. +- The gate PRINTS the per-class table (class → external subrequests → budget + 50/request → headroom) and FAILS if any class exceeds 50. +- HONESTY REQUIREMENT: this gate is a SOURCE-DERIVED inventory, not a live + platform measurement. The gate's evidence string MUST say so verbatim + ("source-derived inventory, not a live Workers Logs metric"). The live + Workers Logs subrequest metric is a SEPARATE measurement and is NOT + available outside `--live` — when the live path cannot produce it, the row + reads `not measured outside --live` and never a fabricated number. + +**`runGoldenCorpusGate()`** — the golden corpora (SC4.a / SC4.b). The +budget audit ORCHESTRATES the corpora so the ONE command that produces the +budget record also proves the corpora are green (the VALIDATION map names +this gate as the enforcement point for SC4.a / SC4.b): +- Spawn `npm run routing:corpus` and `npm run search:corpus` as child + processes (cwd = repo root), exactly as `runCpuLadderGate` spawns + `routing/scripts/measure-route-cpu.mjs` — RE-RUN, never re-implemented. +- FAIL when either child exits non-zero, carrying that child's own failure + output verbatim in the evidence. +- PASS when both exit 0, and the evidence names the two corpora by id. + **`runCacheHeaderGate()`** — the live header probe. Re-use the probe from `scripts/lib/cloudflare-probes.mjs` (import `runAssetHeaderProbe` from it — do NOT fork it) when the credentials/origin are available; otherwise return @@ -298,21 +354,24 @@ Print the observed `cache-control` for the HTML entry and for a hashed Cloudflare's own footnote (the evidence quotes it, it does not interpret it). - - `scripts/lib/budget-gates.mjs` exists and exports `runCpuLadderGate`, `runBundleBudgetGate`, `runKvWriteClassGate`, `runCacheHeaderGate` + - `scripts/lib/budget-gates.mjs` exists and exports `runCpuLadderGate`, `runBundleBudgetGate`, `runKvWriteClassGate`, `runSubrequestClassGate`, `runCacheHeaderGate`, `runGoldenCorpusGate` - `grep -c "measure-route-cpu" scripts/lib/budget-gates.mjs` returns ≥ 1 (the ladder is RE-RUN, not re-implemented) - `grep -c "P95_BUDGET_MS = 8" scripts/lib/budget-gates.mjs` returns ≥ 1 - `grep -c "204800" scripts/lib/budget-gates.mjs` returns ≥ 1 (the BA9 ceiling) - `grep -c "gzipSync" scripts/lib/budget-gates.mjs` returns ≥ 1 - `grep -c "runGuidanceCronTick" scripts/lib/budget-gates.mjs` returns ≥ 1 (the cron zero-write law is source-proven) + - `grep -c "routing:corpus" scripts/lib/budget-gates.mjs` returns ≥ 1 and `grep -c "search:corpus" scripts/lib/budget-gates.mjs` returns ≥ 1 (the golden corpora are RE-RUN) + - `grep -c "subrequest" scripts/lib/budget-gates.mjs` returns ≥ 1 (the 50/request ceiling has an inventory beside it) - export PATH="$HOME/.nvm/versions/node/v24.18.0/bin:$PATH"; cd /home/C-Fu/araharah && node --check scripts/lib/budget-gates.mjs && node -e "import('./scripts/lib/budget-gates.mjs').then(async m=>{for(const k of ['runCpuLadderGate','runBundleBudgetGate','runKvWriteClassGate','runCacheHeaderGate']){const r=m[k]();console.log(r.id+': '+(r.notRun?'NOT RUN':(r.pass?'PASS':'FAIL'))+' — '+String(r.evidence).slice(0,160));}})" + export PATH="$HOME/.nvm/versions/node/v24.18.0/bin:$PATH"; cd /home/C-Fu/araharah && node --check scripts/lib/budget-gates.mjs && node -e "import('./scripts/lib/budget-gates.mjs').then(async m=>{for(const k of ['runCpuLadderGate','runBundleBudgetGate','runKvWriteClassGate','runSubrequestClassGate','runCacheHeaderGate','runGoldenCorpusGate']){const r=m[k]();console.log(r.id+': '+(r.notRun?'NOT RUN':(r.pass?'PASS':'FAIL'))+' — '+String(r.evidence).slice(0,160));}})" - The four budget gates measure and print the CPU p95 (by re-running the + The six budget gates measure and print the CPU p95 (by re-running the existing ladder), the bundle bytes, the KV write classes with the cron's - zero-write count, and the live cache headers — each with a NOT-RUN path - that is never a pass. + zero-write count, the per-class subrequest inventory, the live cache + headers, and the golden corpora (by re-running them) — each with a + NOT-RUN path that is never a pass. @@ -332,13 +391,16 @@ Create `scripts/budget-audit.mjs` — the orchestrator (the same shape as Behaviour: - Accepts an optional `--live` flag. Without it, the cache-header gate is SKIPPED and reported as `NOT RUN (pass --live)`. -- Runs `runCpuLadderGate()`, `runBundleBudgetGate()`, `runKvWriteClassGate()`; - with `--live`, also `runCacheHeaderGate()`. +- Runs `runCpuLadderGate()`, `runBundleBudgetGate()`, `runKvWriteClassGate()`, + `runSubrequestClassGate()`, `runGoldenCorpusGate()`; with `--live`, also + `runCacheHeaderGate()`. - Prints ONE line per gate: `: PASS|FAIL|NOT RUN — `. - Prints the FREE-TIER COMPARISON TABLE to stdout — every ceiling from 07-RESEARCH.md §Pattern 5 with the CURRENT value beside it where the audit measured one, and `not measured` where it did not. This table is the - console form of `docs/BUDGETS.md`. + console form of `docs/BUDGETS.md`. The `Worker subrequests | 50/request` + ceiling MUST appear in this table with the per-class inventory beside it + (never a bare ceiling with no number). - Prints a summary line: `BUDGET AUDIT CLEAN — gate(s) within budget` / `BUDGET AUDIT FAILED — gate(s) over budget` / `BUDGET AUDIT NOT RUN — `. @@ -366,14 +428,37 @@ contain: with rows for: the A* CPU p95 (local ladder), the app bundle gz bytes, the route payload gz bytes, the Worker bundle size, the KV write count per request class (session mint / location pin / guidance envelope / **cron = - 0**), the static asset cache headers, and the KV daily write estimate. + 0**), the static asset cache headers, the KV daily write estimate, the + **subrequests per request class**, and the golden-corpus status. The Current column carries the REAL measured number from the run; the Headroom column is the budget minus the current value; the Evidence column names the gate id that produced it. -4. A `## The cron writes zero` section — one honest paragraph explaining WHY +4. A `## Subrequests per request class` section — the D-06 subrequest row. + It MUST contain a table with one row per Worker request class: `| Request class | External subrequests | Budget (50/request) | Headroom | Source |`. + The table MUST include: + - the route endpoint (`/api/route`) — **0 external subrequests** (the + routing graph comes from the bundled KV binding; the A* search is pure + CPU) — with the source site cited; + - the cron guidance tick — the ONLY recurring external-subrequest class + (one Telegram Bot API send per due envelope) — with the source site + cited; + - one row for every other Worker request class the route table answers. + A row for KV operations with the note that KV operations are NOT + subrequests — they are billed against the separate KV-operations ceiling + (1,000/invocation) and are tracked in the KV-write row. + Every row whose count cannot be derived from the source MUST read + `not measured` with the reason — never `0`, never an estimate. + The section MUST state, in one sentence, that the subrequest counts are a + SOURCE-DERIVED INVENTORY, not a live Workers Logs metric; where the live + metric cannot be produced, the record says `not measured outside --live` + and does not substitute a number. +5. A `## The cron writes zero` section — one honest paragraph explaining WHY (the tick lists, reads, and sends; it never writes), and naming the source gate that proves it. -5. A `## Cross-metro CPU degradation (the c3 class)` section — the honest +6. A `## Golden corpora` section — one short paragraph naming the two + corpora (`routing:corpus`, `search:corpus`), the gate id that re-runs + them (`golden-corpus`), and the outcome from THIS run. +7. A `## Cross-metro CPU degradation (the c3 class)` section — the honest escalation from Phase 6: short product routes (2–8 km) measure 2–8 ms (inside the budget); cross-metro routes (20–30 km) measured p95 38–93 ms and degrade HONESTLY to the router-pending state; the mitigation ladder is @@ -383,15 +468,15 @@ contain: the ALT landmark preprocessing (a NEXT-MILESTONE item, not this phase). Cite `routing/scripts/measure-route-cpu.mjs`'s LADDER HISTORY and the 06-04 plan summary as the evidence. -6. A `## Static assets` section — the `_headers` policy and the observed live +8. A `## Static assets` section — the `_headers` policy and the observed live headers (or an honest "not measured in this run"). -7. A `## Statement` section — the honest one-paragraph claim, naming which +9. A `## Statement` section — the honest one-paragraph claim, naming which numbers are measured, which are NOT measured (the live Worker CPU metric — the local ladder and the live Workers Logs CPU number are DIFFERENT measurements and neither substitutes for the other), and which are estimated. -8. A `## Signature` block with the maintainer's name and date lines left as - `________________` (the human signs at the launch gate). +10. A `## Signature` block with the maintainer's name and date lines left as + `________________` (the human signs at the launch gate). FILL the tables with the REAL run output — run `npm run budget-audit` and paste the printed numbers. Do NOT invent, round optimistically, or copy a @@ -401,7 +486,9 @@ number from another phase's summary. - `scripts/budget-audit.mjs` exists and contains the literals `BUDGET AUDIT CLEAN`, `BUDGET AUDIT FAILED`, `BUDGET AUDIT NOT RUN` - `grep -c "process.exit" scripts/budget-audit.mjs` returns ≥ 3 - `grep -c '"budget-audit"' package.json` returns exactly 1 - - `docs/BUDGETS.md` exists and contains the literals `## Free-plan ceilings`, `## Current measurements`, `## The cron writes zero`, `## Cross-metro CPU degradation`, `## Static assets`, `## Statement`, `## Signature` + - `grep -c "runGoldenCorpusGate\|runSubrequestClassGate" scripts/budget-audit.mjs` returns ≥ 2 (the CLI runs BOTH new gates) + - `docs/BUDGETS.md` exists and contains the literals `## Free-plan ceilings`, `## Current measurements`, `## Subrequests per request class`, `## The cron writes zero`, `## Golden corpora`, `## Cross-metro CPU degradation`, `## Static assets`, `## Statement`, `## Signature` + - `grep -c "50/request\|50 /request" docs/BUDGETS.md` returns ≥ 1 (the subrequest ceiling is stated) - `npm run budget-audit` exits 0 on the current tree and prints `BUDGET AUDIT CLEAN` @@ -409,8 +496,10 @@ number from another phase's summary. `npm run budget-audit` measures and prints every budget with its free-plan - ceiling, exits 0/1/2 correctly, and `docs/BUDGETS.md` carries the record - with the real numbers, the c3 escalation, and a human signature block. + ceiling (including the per-request-class subrequest inventory and the + golden-corpus status), exits 0/1/2 correctly, and `docs/BUDGETS.md` + carries the record with the real numbers, the subrequests section, the + c3 escalation, and a human signature block. @@ -434,6 +523,7 @@ number from another phase's summary. | T-07-20 | Tampering | `web/public/_headers` — a stale HTML entry pointing at deleted hashed assets | mitigate | Only `/assets/*` (content-hashed, immutable for 31556952 s) gets the immutable rule; the HTML entry is `max-age=0, must-revalidate`. The live header probe prints the OBSERVED headers as evidence rather than assuming the policy took effect. | | T-07-21 | Information Disclosure | the budget audit's own output | accept | The audit prints sizes, counts, timings and header values only — no secret, no token, no coordinate. The bot token and webhook secret live only in `bot/.dev.vars` / Worker secrets and are never read by this tool. | | T-07-22 | Spoofing | the budget verdict — a missing graph binary or build reported as "within budget" | mitigate | A missing graph binary returns `{pass:false, notRun:true}` and the CLI maps a NOT RUN to exit code 2 with `BUDGET AUDIT NOT RUN` — a skip is never a pass. | +| T-07-23b | Spoofing | the subrequest inventory — a fabricated zero beside the 50/request ceiling | mitigate | `runSubrequestClassGate` derives every class's external-subrequest count from the Worker's own route table and PRINTS the source site; a count that cannot be derived reads `not measured` with its reason — never `0`, never an estimate. The gate's evidence states that the inventory is SOURCE-DERIVED, not a live Workers Logs metric, and the record names the live metric `not measured outside --live` rather than substituting a number. | @@ -454,9 +544,10 @@ NEVER exit 0 while reporting a measurement as skipped. - `web/public/_headers` exists and reaches `web/dist/`, carrying the immutable hashed-asset rule and the revalidating HTML rule, and nothing else. -- `npm run budget-audit` measures and prints the A* CPU p95 (by re-running the existing ladder), the bundle bytes, the KV write classes with the cron's zero-write count, and the cache headers, each against a documented free-plan ceiling. +- `npm run budget-audit` measures and prints the A* CPU p95 (by re-running the existing ladder), the bundle bytes, the KV write classes with the cron's zero-write count, the per-request-class subrequest inventory, the golden corpora (by re-running them), and the cache headers, each against a documented free-plan ceiling. +- The subrequest row carries a per-request-class inventory (the route endpoint = 0 external subrequests; the cron tick's Telegram send = the only recurring class; KV ops tracked separately against their own ceiling), and any count that cannot be derived from the source reads `not measured` — never `0`, never an estimate. - The exit codes are 0 / 1 / 2 and a NOT RUN is never a pass. -- `docs/BUDGETS.md` carries the record with the real numbers, the c3 cross-metro degradation class, the cron zero-write explanation, and a human signature block. +- `docs/BUDGETS.md` carries the record with the real numbers, the `## Subrequests per request class` section, the `## Golden corpora` section, the c3 cross-metro degradation class, the cron zero-write explanation, and a human signature block. diff --git a/.planning/phases/07-hardening-private-launch/07-RESEARCH.md b/.planning/phases/07-hardening-private-launch/07-RESEARCH.md new file mode 100644 index 0000000..6a1a2cc --- /dev/null +++ b/.planning/phases/07-hardening-private-launch/07-RESEARCH.md @@ -0,0 +1,748 @@ +# Phase 7: Hardening + Private Launch - Research + +**Researched:** 2026-10-25 +**Domain:** WebView fullscreen/landscape hardening, failure-state QA, privacy/budget audit, free-tier launch gates +**Confidence:** HIGH for repository structure and documented platform limits; MEDIUM for per-client fullscreen behavior until the real-device walk; MEDIUM for battery result because it must be measured + + +## User Constraints (from CONTEXT.md) + +### Locked Decisions + +### Privacy audit (SC3) +- **D-01:** A **consolidated automated audit script** (`npm run privacy-audit`, repo tool): grep gates over ALL src trees (no storage-API writes outside the platform allowlist — the phase-3 gate's law extended repo-wide; no coordinate/payload logging — the category-only vocabulary verified by schema scan), a KV listing probe (only TTL-keyed namespaces), a bundle scan (**zero telemetry: no analytics, no beacons — the ONLY runtime fetches are OpenFreeMap tiles, the Telegram API, and the app's own Worker endpoints**), plus a **signed written audit record** citing the evidence (PRIV-02/GEO-03 re-verified end-to-end). Reusable by forks (GPL audit tool). +- **D-02:** **Zero telemetry in v1** — opt-in telemetry stays an IDEAS.md aspiration post-validation; nothing to opt into yet, nothing to audit but absence. + +### Device matrix + battery (SC1, PX-04) +- **D-03:** **Fullscreen on guidance start** via `requestFullscreen()` behind the 8.0 capability guard (phase-6 guard precedent), with orientation-lock used where available; landscape usability per device in the QA pass; landscape layout fixes are CSS-only media queries (tokens binding; the cockpit/banner/card geometry adapts, the map stays Rams-constant). +- **D-04:** The **battery target is a RECORDED real measurement**: one Android guidance run ≥30 min screen-on, battery % read at start/end (method + caveats written into the gate file); the D-15 distance-throttle is the software half. The <10%/hr target is recorded as measured, never synthesized (no bench harness — over-engineering for one phone). +- **D-05:** The QA pass is **one consolidated walk**: Android + iOS + Desktop + browser × (boot, search→route, guidance, minimize/resume, trip-end, theme flip, landscape/fullscreen) — a single checklist in the phase gate file; user walks once; NOT-RUN legs recorded honestly (the phase 3/4 protocol; iOS legs if the device is available — Phase 4 verified iOS present). + +### Budgets + deferred debt (SC4) +- **D-06:** A **consolidated budget record** (`npm run budget-audit` output + written record checked into verification): Worker CPU/subrequests (06-04's measured ladder re-run against the live graph), bundle sizes (BA9 et al.), KV write counts per request class (cron = 0 writes — the free-plan law), asset cache headers. Current numbers vs free-tier limits, in one document. +- **D-07:** **Debts paid here**: (1) 02-REVIEW's 3 deferred INFOs — IN-01 (dead `ParsedInitData` export + unreachable branch + read-helper intent comments), IN-02 (stale wrangler.jsonc comment), IN-03 (exact-900s boundary test — the security-critical comparator pin); (2) the /cari menu: re-run `register-commands.mjs` (scopes were re-armed in phase 4) + record the Telegram non-default-scope defect honestly in the limitations doc; (3) phase 3's stale-fix assumption notes (A1/A3) reviewed and closed or re-pointed. **NOT here**: ALT landmarks (c2) — committed to the v1.x milestone, not this phase. + +### Known-limitations doc (SC4) +- **D-08:** `docs/KNOWN-LIMITATIONS.md` in-repo, written for **drivers AND forks**: OSM data gaps (no traffic confidence, no lane guidance, speed limits where mapped), cross-metro CPU degradation (the c3 class), the /cari menu defect + its workaround, beta quality statement, and the roadmap pointers (WASM-router track, ALT, engine B). Surfaced from the bot: `/help` gains one line pointing at the doc (strings-module copy). + +### Launch definition +- **D-09:** **Private launch = checklist-signed private beta**: the user's chosen first drivers (friends/family/testers in KL) receive the bot link; word-of-mouth distribution. No app store, no marketing, no landing page (post-v1 idea). The launch checklist: known-limitations shipped · privacy audit signed · budget record green · full device matrix walked · `/help` + `/privacy` accurate · repo pushed public under GPL-3.0. **The milestone completes when the checklist is signed** — the launch act itself is the user's, not the workflow's. + +### OpenCode's Discretion +- Audit script file layout (scripts/ + test/ split), gate vocabulary, exit codes +- Battery recording method details; landscape media-query breakpoints within tokens +- INFO-fix mechanics; the boundary test's fixture form +- Known-limitations doc structure/tone; the /help one-liner copy (en/ms) + +### Deferred Ideas (OUT OF SCOPE) +- ALT landmark preprocessing (c2) — v1.x milestone lever (measured decision, phase 6) +- Engine B (fully client-side webview router) — the static-hosted endgame track +- WASM-router OSS project — backend #2's standalone evolution +- Opt-in telemetry — post-validation (IDEAS.md) +- Voice guidance, live trip share, toll toggle, speed-limit display, traffic ETA — v1.x table +- Bitmap map snapshots in turn-cards — v1.x +- Public launch (landing page, marketing, app-store presence) — post-v1 + + + +## Phase Requirements + +| ID | Description | Research Support | +|----|-------------|------------------| +| PX-04 | Guidance supports fullscreen and the screen remains usable in landscape (driving posture) | Telegram Bot API 8.0 offers `requestFullscreen`, `exitFullscreen`, `isFullscreen`, and `fullscreenFailed`; browser Fullscreen API is gesture-gated and can reject; capability wrapper, CSS-only landscape rules, mock/browser tests, and consolidated real-device gate are specified below. | +| PRIV-02 (re-verify) | Backend holds no persistent per-user location history/profile; only TTL-expiring state, verifiable by architecture audit | Existing source gates plus a repository-wide audit tool, live KV key/expiry listing probe, log-vocabulary scan, bundle fetch-host allowlist, and signed audit record are specified below. | +| GEO-03 (re-verify) | User location is never written to persistent storage; only ephemeral TTL server state | Current client storage gate, Worker KV TTL gate, live key/expiry inspection, and end-to-end boot/guidance/trip-end probe are specified below. | + + +## Summary + +Phase 7 is best planned as **four independently verifiable gates joined by one honest launch checklist**, not as a broad feature phase. The code already contains most of the mechanisms that need proving: a narrow Telegram WebApp contract with version guards, memory-only client state, TTL-only KV writes, route/guidance replay fixtures, a deterministic A* CPU measurement, and a capture harness. The work is to consolidate these into reproducible gates, close the explicit UX gap (fullscreen/landscape), exercise every failure class, and preserve an evidence trail that a fork or reviewer can rerun. + +The platform answer is now clear. Telegram Bot API 8.0 introduced native Mini App fullscreen plus `requestFullscreen`/`exitFullscreen`, `isFullscreen`, `fullscreenChanged`, `fullscreenFailed`, and `lockOrientation`/`unlockOrientation`; the official guidance says Mini Apps can use portrait or landscape fullscreen and that content must respect safe/content safe areas. Therefore the primary path should call **Telegram's Mini App fullscreen API** at guidance start, not the DOM `Element.requestFullscreen()` API: the DOM API is only “Limited availability,” requires transient user activation, and can reject. A DOM fallback is optional and must be silent. Orientation should be locked only through Telegram's `lockOrientation()` when available, because generic `screen.orientation.lock()` is not supported in Android WebView and may reject elsewhere. + +**Primary recommendation:** implement a single `fullscreen.ts` Telegram capability wrapper that (1) requires Bot API >= 8.0 and method presence, (2) calls `Telegram.WebApp.requestFullscreen()` at the guidance-start edge, (3) observes `fullscreenChanged`/`fullscreenFailed`, and (4) treats failure as a non-error fallback to a fully usable portrait/landscape layout; then prove it through one automated matrix plus one honest four-client walk. + +## Architectural Responsibility Map + +| Capability | Primary Tier | Secondary Tier | Rationale | +|------------|-------------|----------------|-----------| +| Fullscreen request / orientation lock | Browser / Client | Telegram client | The Mini App owns the capability guard and calls Telegram's Bot API 8.0 methods from the active gesture/start boundary. | +| Landscape cockpit geometry | Browser / Client | CDN / Static | CSS media queries and safe-area variables live in the web app; the map cartography remains unchanged. | +| Failure-state rendering | Browser / Client | API / Backend | The client must render honest states; backend null/error semantics supply stable categories. | +| Version-gated old-client fallback | Browser / Client | Telegram client | Method absence and version checks occur in the web app; the Telegram client decides support. | +| Source privacy gates | API / Backend | Browser / Client | One repo tool scans all `src` trees and production bundles; failures must be deterministic exit codes. | +| Live KV expiry/absence proof | Database / Storage | API / Backend | Wrangler lists actual namespace keys and expiration metadata; source gates prove every writer carries TTL. | +| Coordinate-free logging proof | API / Backend | CDN / Static | Worker logs are category-only; the audit scans log call sites and runtime output. | +| Runtime network allowlist proof | Browser / Client | CDN / Static | Production bundle scan proves only approved tile, Telegram, and own-Worker hosts exist. | +| Worker CPU/subrequest accounting | API / Backend | Database / Storage | Worker invocation metrics, R2/KV/Telegram subrequests, and cron behavior determine free-tier headroom. | +| Golden-route regression | API / Backend | Browser / Client | Routing/search corpus CLIs and in-suite assertions already exist and should be invoked together. | +| Static-asset cache policy | CDN / Static | — | `_headers` controls browser cache behavior for fingerprinted Vite assets and HTML. | +| Battery measurement | Browser / Client | Human checkpoint | The app controls GPS cadence; the human records phone battery before/after a real run. | +| Launch completion | Browser / Client | API / Backend | The human signs the checklist after all automated and physical evidence exists. | + +## Project Constraints (from AGENTS.md) + +- Telegram bot + Telegram Mini App is the product; no independent mobile app in v1. [VERIFIED: AGENTS.md] +- One Cloudflare Worker serves static assets, `/telegram/webhook`, and `/api/*`; Workers superseded Pages. [VERIFIED: AGENTS.md; bot/wrangler.jsonc] +- Every service must be open-source or genuinely free-tier; no paid APIs. [VERIFIED: AGENTS.md] +- Privacy: no location history/profiles without explicit opt-in; v1 has zero telemetry. [VERIFIED: AGENTS.md; D-02] +- KL metro is the v1 region. [VERIFIED: AGENTS.md] +- Telemetry Rams values are binding: exact palette, Space Grotesk/Geist typography, 8px grid, zero border radius, no shadows/blur/glass. [VERIFIED: AGENTS.md] +- Telemetry Rams tokens are the single styling authority; the `design-taste-frontend` skill's existing-design-system special case applies, but its defaults do not override Rams tokens. This phase's UI surface is limited to landscape media queries and failure-state rendering. [VERIFIED: AGENTS.md; project skill note] +- Use npm workspaces and the repo's custom Node test harnesses; no third-party test framework is currently established. [VERIFIED: AGENTS.md] +- Git hygiene: generated output/local secrets are ignored; public GPL-3.0 publication is part of the launch checklist. [VERIFIED: AGENTS.md; D-09] +- Before direct file changes, use the GSD workflow; planning artifacts are the intended output here. [VERIFIED: AGENTS.md] + +## Standard Stack + +### Core + +| Library | Version | Purpose | Why Standard | +|---------|---------|---------|--------------| +| Telegram Mini Apps WebApp JS | 8.0 API floor | Native Mini App fullscreen, orientation lock, safe-area fields/events | Official platform API; D-03 already locks the 8.0 guard. [CITED: https://core.telegram.org/bots/webapps] | +| Browser Fullscreen API | Platform feature-detect | Optional DOM fallback outside Telegram | Standard web API but limited support and transient-user-activation requirement. [CITED: https://developer.mozilla.org/en-US/docs/Web/API/Element/requestFullscreen] | +| Screen Orientation API | Platform feature-detect | Diagnostics only; do not make it the primary lock | Not supported in Android WebView; rejects for several reasons. [CITED: https://developer.mozilla.org/en-US/docs/Web/API/ScreenOrientation/lock; Chromium issue 40939281] | +| Cloudflare Workers + KV + R2 | Free plan | Runtime, ephemeral state, road graph | Existing settled stack; official current limits documented below. [VERIFIED: repo; CITED: Cloudflare limits/pricing/KV docs] | +| Wrangler | 4.151.3 declared | KV listing, deploy/canary, bundle inspection | Already installed in `bot`; `kv key list` supports namespace id and prefix. [VERIFIED: bot/package.json; CITED: https://developers.cloudflare.com/kv/reference/kv-commands/] | +| Node.js test harnesses | Repo-local | Deterministic source/route/guidance gates | Existing `node:test`-style runners avoid a new test dependency. [VERIFIED: repo package scripts] | +| Playwright | 1.50.0 declared | Browser viewport/orientation/failure-state captures | Already present in `web`; can exercise landscape media queries. [VERIFIED: web/package.json] | +| Vite | 8.3.5 declared | Production bundle and harness bundle | Already the Mini App bundler; output feeds Workers static assets. [VERIFIED: web/package.json] | + +### Supporting + +| Library | Version | Purpose | When to Use | +|---------|---------|---------|-------------| +| Android Battery Historian / `adb` | Platform tool | Optional corroboration of a recorded phone run | Use only if already available; D-04 requires one simple recorded measurement, not a lab harness. [CITED: https://developer.android.com/topic/performance/power/battery-historian] | +| `node:zlib` | Node builtin | Gzip size measurements for budget audit | Use in `budget-audit`; no new dependency. [VERIFIED: routing/scripts/route-corpus.mjs precedent] | +| `process.hrtime.bigint()` | Node builtin | Deterministic A* CPU measurement | Reuse existing `routing/scripts/measure-route-cpu.mjs`. [VERIFIED: repo] | + +### Alternatives Considered + +| Instead of | Could Use | Tradeoff | +|------------|-----------|----------| +| Telegram `WebApp.requestFullscreen()` | DOM `documentElement.requestFullscreen()` | DOM API may reject without transient activation and has limited iOS/WebView support; use only as optional fallback. [CITED: MDN] | +| Telegram `lockOrientation()` | `screen.orientation.lock("landscape")` | Generic lock is unavailable in Android WebView and can reject; Telegram API locks current orientation, not an arbitrary target. [CITED: MDN; Telegram docs] | +| A single new audit framework | Existing custom Node runners plus one orchestration script | Fits the repo, GPL-friendly, and avoids a dependency for a finite gate vocabulary. [VERIFIED: repo conventions] | +| Full Android battery instrumentation | One human-recorded start/end run | D-04 explicitly forbids over-engineering a bench harness. [VERIFIED: D-04] | + +**Installation:** + +```bash +# No new runtime dependency is required for the recommended implementation. +# Existing workspace installs should already provide: +npm install +npm run bot:test +npm run web:test +npm run routing:test +npm run search:test +``` + +**Version verification:** The environment used for this research could not run `node`/`npm` (`node: command not found`), so current registry versions were not freshly queried. Declared repository versions above were read from package manifests and are current **for this checkout**, not independently registry-verified. [VERIFIED: repo manifests] + +## Architecture Patterns + +### System Architecture Diagram + +```text +[Driver starts guidance] + | + v +[web/src/telegram/fullscreen.ts capability wrapper] + | | | + | | +-- absent/failure --> [portrait + landscape CSS remains usable] + | +-- Telegram >= 8.0 --> requestFullscreen() --> fullscreenChanged/failed + | | + | +-- lockOrientation() where available + v +[Guidance cockpit + CSS landscape media queries] + | | | + | | +-- [map cartography stays Rams-constant] + | +-- [safe-left/right/top/bottom vars] + +-- [route/guidance failure state] --> [designed empty/error/off-road/no-geometry card] + +[Local automated gates] + | + +--> npm run privacy-audit + | +-- source grep: storage APIs / coordinate logs / runtime fetch hosts + | +-- production bundle scan: zero analytics/beacons/unapproved hosts + | +-- live KV listing: key families + expirations only + | + +--> npm run budget-audit + | +-- route corpus + search corpus + CPU measurement + | +-- bundle bytes + KV write classes + asset cache headers + | + +--> [one human device matrix + Android battery run] + | + v + [07-LAUNCH-GATE.md signed] + | + v + [private beta links distributed by human] +``` + +### Recommended Project Structure + +```text +scripts/ +├── privacy-audit.mjs # orchestration and human-readable evidence report +├── budget-audit.mjs # orchestration and current-vs-limit report +└── lib/ + ├── source-gates.mjs # comment-stripped source scans + ├── bundle-gates.mjs # production bundle scans + └── cloudflare-probes.mjs # KV list + live headers/metrics probes +web/src/telegram/ +└── fullscreen.ts # Telegram 8.0 capability wrapper + fallback +web/test/ +└── fullscreen.test.mjs # capability/event/failure vectors +web/scripts/ +└── capture.mjs # extend existing harness with landscape/failure legs +docs/ +├── KNOWN-LIMITATIONS.md +└── BUDGETS.md # checked-in current-vs-limit record +.planning/phases/07-hardening-private-launch/ +└── 07-LAUNCH-GATE.md # signed checklist, battery method/result, device verdicts +``` + +### Pattern 1: Capability-Guarded Native Fullscreen with Honest Fallback + +**What:** At the guidance-start edge, request Telegram Mini App fullscreen only when Bot API 8.0 and the method are present. Listen to `fullscreenChanged` and `fullscreenFailed`. A rejected request is not an app error: guidance continues, and the layout remains usable in either orientation. Use `lockOrientation()` only to lock the **current** orientation; do not attempt to force landscape. + +**When to use:** Guidance has just started and the driver has initiated the action. This is the only phase where occupying the whole screen is appropriate. + +**Example:** +```typescript +// Source: https://core.telegram.org/bots/webapps +export interface TelegramWebAppLike { + // existing members... + requestFullscreen?(): void; + exitFullscreen?(): void; + isFullscreen?: boolean; + lockOrientation?(): void; + unlockOrientation?(): void; + onEvent?(event: string, cb: (...args: unknown[]) => void): void; + offEvent?(event: string, cb: (...args: unknown[]) => void): void; +} + +export function requestGuidanceFullscreen(wa: TelegramWebAppLike | null): boolean { + if ( + wa === null || + typeof wa.requestFullscreen !== "function" || + typeof wa.isVersionAtLeast !== "function" || + !wa.isVersionAtLeast("8.0") + ) return false; + try { + wa.requestFullscreen(); + return true; + } catch { + return false; // absence/failure is a fallback, never a broken guidance state + } +} +``` + +**Required behavior:** +- Extend the existing narrow `TelegramWebAppLike` contract and Mock-Telegram fake in the same change, following the repo's established “contract + fake + test together” law. [VERIFIED: web/src/telegram/webapp.ts] +- Treat `fullscreenFailed` as a category event, not a user-facing error. +- Do not log coordinates, user identifiers, or route payloads in the fallback path. [VERIFIED: D-01; existing D-14 category-only law] +- Do not block guidance start on fullscreen success; the call is best effort. + +### Pattern 2: Landscape as CSS Geometry Adaptation, Not a New Map + +**What:** Keep the map Rams-constant. Adapt only the cockpit/banner/card geometry with media queries and safe-area variables. The existing HTML already has `viewport-fit=cover`, and `safe-area.ts` writes `--safe-top/right/bottom/left` from CSS env and Telegram 8.0 insets, taking the larger per edge. [VERIFIED: web/index.html; web/src/telegram/safe-area.ts] + +**When to use:** Always. Portrait remains the default; landscape is an additional constraint, not a separate screen. + +**Example:** +```css +/* Source: https://webkit.org/blog/7929/designing-websites-for-iphone-x/ + Tokens remain binding; no new colors, radii, or shadows. */ +@media (orientation: landscape) and (max-height: 560px) { + .guide-banner { + min-height: 72px; + padding-left: max(var(--spacing-space-md), var(--safe-left)); + padding-right: max(var(--spacing-space-md), var(--safe-right)); + } + + .guide-list { + max-width: 420px; + max-height: calc(100dvh - 104px - var(--safe-top) - var(--safe-bottom)); + } +} +``` + +**Cautions:** +- `env(safe-area-inset-*)` is not a replacement for margins; combine it with normal token spacing using `max()` and provide fallback declarations. [CITED: WebKit safe-area guidance] +- The current shell uses `height: 100%` and `position: fixed`; test whether `100dvh`/`100svh` is needed when Telegram chrome and orientation changes occur. Do not change the sizing model without a failing capture. [VERIFIED: web/src/shell.css] + +### Pattern 3: Failure-State Matrix as Data + +**What:** Represent each failure class as a named vector with its expected DOM/state token and action. The repo already uses named vectors and custom runners. The phase should enumerate empty, error, timeout, off-road, no-geometry, and older-client fallback rather than ad-hoc screenshots. + +**When to use:** Every failure-state change and every client-matrix rerun. + +**Example:** +```javascript +const FAILURE_VECTORS = [ + { id: "empty-route", trigger: "route options=[]", expect: "pending-card" }, + { id: "route-error", trigger: "route source throws", expect: "pending-card" }, + { id: "no-geometry", trigger: "polyline decodes to <2 points", expect: "pending-card" }, + { id: "off-road", trigger: "3 fixes beyond accuracy-aware threshold", expect: "rerouting-or-no-reroute" }, + { id: "location-timeout", trigger: "LocationManager returns null after timeout", expect: "denied-or-awaiting-card" }, + { id: "old-client", trigger: "WebApp version < 8.0 / methods absent", expect: "guidance-usable-without-fullscreen" }, +]; +``` + +**Required behavior:** +- No blank screen, broken layout, fabricated route, or crash. [VERIFIED: SC2; Phase 3/5/6 conventions] +- Off-road is already a calm state machine with grace, storm guard, and `no_reroute` degradation; harden its rendering rather than redesigning it. [VERIFIED: web/src/guidance/guidance.ts] +- Route `null`/throw already renders the designed pending card; audit coverage, not behavior. [VERIFIED: web/src/route/route.ts] + +### Pattern 4: Consolidated Audit as Deterministic Exit-Code Tool + +**What:** One orchestration command runs source scans, bundle scans, live probes, and existing corpus gates. It prints evidence and exits 0 only when every required gate passes; 1 for a gate failure; 2 for missing credentials/environment so “not run” is not mistaken for “pass.” + +**When to use:** Before every phase wave merge and at the launch gate. + +**Example:** +```javascript +// Pattern, not copied from an upstream source. +const results = [ + await runStorageGate(), + await runCoordinateLogGate(), + await runRuntimeHostGate(), + await runBundleTelemetryGate(), + await runKvExpiryProbe(), +]; + +for (const result of results) { + console.log(`${result.id}: ${result.pass ? "PASS" : "FAIL"} — ${result.evidence}`); +} +process.exit(results.every((r) => r.pass) ? 0 : 1); +``` + +### Pattern 5: Reproducible Budget Record + +**What:** Reuse the existing corpus and CPU measurements, add bundle/KV/cache measurements, and compare every current number with a documented free-plan ceiling. Store both the machine-readable output and a human-written record. + +**When to use:** Phase close and launch checklist. + +**Required free-plan reference values:** [CITED: https://developers.cloudflare.com/workers/platform/limits/; https://developers.cloudflare.com/kv/platform/limits/; https://developers.cloudflare.com/workers/platform/pricing/] + +| Resource | Free-plan ceiling | +|----------|-------------------| +| Worker HTTP CPU | 10 ms/invocation | +| Cron CPU | 10 ms/invocation | +| Worker subrequests | 50/request | +| Worker requests | 100,000/day | +| Worker memory | 128 MB/isolate | +| Worker bundle | 64 MiB uncompressed | +| Static assets | free and unlimited requests; 20,000 files/version; 25 MiB/file | +| KV reads | 100,000/day | +| KV writes to different keys | 1,000/day | +| KV writes to same key | 1/second | +| KV list requests | 1,000/day | +| KV storage | 1 GB/account and namespace | +| KV operations/invocation | 1,000 | +| Workers Logs Free | 200,000 events/day; 3-day retention | + +### Anti-Patterns to Avoid + +- **DOM fullscreen as the primary path:** it is gesture-gated and limited availability; the Telegram-native API is the supported product surface. [CITED: MDN] +- **Forcing landscape through `screen.orientation.lock("landscape")`:** unsupported in Android WebView and rejects in several states; Telegram `lockOrientation()` locks the current orientation. [CITED: MDN; Chromium issue] +- **Treating a failed fullscreen request as guidance failure:** the fallback is a deliberate supported state. +- **Grep-only privacy audit:** source scans do not prove production bundles contain no analytics or unapproved hosts; include a bundle scan. [CITED: D-01] +- **Listing KV keys but ignoring `expiration`:** key presence alone does not prove TTL-only; inspect the expiry metadata returned by the list API. [CITED: Cloudflare KV list-keys docs] +- **Calling the cron “zero writes” without a runtime probe:** the cron lists/reads and sends to Telegram; D-06 requires measured write counts by request class. +- **Synthesizing battery results:** D-04 requires a recorded real run and caveats. +- **Marking unavailable device legs PASS:** follow the Phase 3/4/6 honesty protocol; NOT RUN is not PASS. [VERIFIED: existing device gates] +- **Redesigning the visual system during hardening:** Rams tokens remain binding; this phase adapts geometry only. + +## Don't Hand-Roll + +| Problem | Don't Build | Use Instead | Why | +|---------|-------------|-------------|-----| +| Mini App fullscreen capability detection | A version-string parser or client-name heuristic | Existing `isVersionAtLeast("8.0")` plus method-presence guard | Official version/floor contract; method absence still matters. [CITED: Telegram docs] | +| Orientation locking | A custom native-bridge protocol | Telegram `lockOrientation()`/`unlockOrientation()` with fallback | Native WebView orientation lock is unavailable; Telegram exposes the supported product API. [CITED: MDN; Telegram docs] | +| Test framework | A new assertion/lint framework | Repo-local Node runners and named vectors | Existing custom harness is the established convention. [VERIFIED: repo] | +| CSS safe-area probing | New per-component inset logic | Existing `safe-area.ts` + CSS `env()` + Telegram `safeAreaInset` union | One authority already exists; extend it rather than duplicating. [VERIFIED: web/src/telegram/safe-area.ts] | +| Cloudflare KV inventory | A bespoke dashboard scraper | `wrangler kv key list --namespace-id ... --prefix ...` | Official CLI supports namespace/binding/prefix. [CITED: Cloudflare docs] | +| Static cache policy | Worker code that special-cases asset URLs | `web/public/_headers` rules for hashed assets and HTML | Workers static assets natively consume `_headers`; default is `public, max-age=0, must-revalidate`. [CITED: Cloudflare static-assets headers docs] | +| Route CPU benchmark | A new profiler | `routing/scripts/measure-route-cpu.mjs` | Deterministic 25-route sample, warm-up, p95<=8 ms gate already exists. [VERIFIED: repo] | +| Golden search/route acceptance | New ad-hoc queries | `search:corpus`, `routing:corpus`, and their in-suite gates | Same measurement is already shared by CLI and test suite. [VERIFIED: repo] | +| Battery lab harness | A synthetic bench | One recorded human run | D-04 explicitly prohibits over-engineering. | +| Launch tracking system | A task tracker or landing page | Signed Markdown gate checklist | D-09 defines the gate file as the completion artifact. | + +**Key insight:** Nearly every risky part of this phase already has a local authority: source gates, a typed Telegram facade, a safe-area resolver, route/search corpus runners, and a CPU measurement. Consolidation and evidence are lower-risk than new infrastructure. + +## Common Pitfalls + +### Pitfall 1: Requesting Fullscreen Outside the User-Activation Window + +**What goes wrong:** A DOM `requestFullscreen()` call rejects with a type/permission error if the document lacks transient user activation or permission policy permits it. [CITED: MDN] + +**Why it happens:** Guidance activation may happen after asynchronous location/routing work, losing the initiating gesture. + +**How to avoid:** Prefer Telegram's `requestFullscreen()` at the guidance-start transition. If a DOM fallback is retained, invoke it only in the direct click handler and catch rejection. + +**Warning signs:** `fullscreenFailed`, `fullscreenerror`, or no `fullscreenChanged` event after guidance starts. + +### Pitfall 2: Assuming Orientation Lock Exists Because the Method Name Does + +**What goes wrong:** `screen.orientation.lock()` is absent or nonfunctional in Android WebView; elsewhere it can reject with `InvalidStateError`, `SecurityError`, `NotSupportedError`, or `AbortError`. [CITED: MDN; Chromium issue 40939281] + +**Why it happens:** Presence of an API is treated as support. + +**How to avoid:** Use Telegram `lockOrientation()` only after the Bot API 8.0/method guard. It locks the current orientation; never assume it can force landscape. Treat rejection as non-error. + +### Pitfall 3: Ignoring Telegram's `safeAreaInset` in Fullscreen + +**What goes wrong:** Fullscreen content overlaps Telegram controls or the device notch/status area. Telegram documents separate `safeAreaInset` and `contentSafeAreaInset` fields and events. [CITED: Telegram docs] + +**Why it happens:** Portrait CSS works, but fullscreen/landscape changes the insets. + +**How to avoid:** Re-resolve safe insets on `safeAreaChanged` and `contentSafeAreaChanged`; current code computes them once at boot. Add an update path and test a changed-inset event. + +### Pitfall 4: Using `100vh` as if It Were Stable + +**What goes wrong:** Rotating or entering fullscreen changes visible viewport size; fixed elements can be clipped or overlap. + +**Why it happens:** CSS assumes a static viewport. + +**How to avoid:** Verify `100dvh`/stable-height behavior in Telegram Android/iOS captures. Keep the current fixed-shell model unless a failing leg proves a change. + +### Pitfall 5: Source Privacy Greps Produce False Confidence + +**What goes wrong:** A source tree is clean, but a production dependency or built bundle contains analytics, a beacon, or a coordinate-bearing payload. + +**Why it happens:** The scan stops at first-party source. + +**How to avoid:** Scan production `web/dist` and dependency manifests for analytics/beacon/network hosts, and verify the runtime fetch allowlist: OpenFreeMap tiles, Telegram API, and the app's own Worker. [CITED: D-01] + +### Pitfall 6: Naive Comment Stripping or String Matching + +**What goes wrong:** A prose mention triggers a ban, or a dynamic URL/host evades a literal string scan. + +**Why it happens:** Grep gates are treated as parsers. + +**How to avoid:** Reuse the repo's comment-stripped source scan, but parse manifests/imports and enumerate network call sites. Prefer positive allowlists for runtime hosts over negative token lists. + +### Pitfall 7: KV Listing Without Expiry or Pagination + +**What goes wrong:** Keys appear TTL-less because the probe ignores the `expiration` field or stops before `list_complete`. [CITED: Cloudflare KV list docs] + +**Why it happens:** The command output is treated as a simple key array. + +**How to avoid:** Parse key name, expiration, pagination cursor, and `list_complete`; assert every live key family has an expiry and report the oldest/newest expiry. + +### Pitfall 8: CPU Measurement That Does Not Match the Live Request + +**What goes wrong:** A local benchmark passes but the deployed `/api/route` still exceeds 10 ms, or vice versa. + +**Why it happens:** Process wall/CPU time, Worker CPU accounting, and graph warm state differ. + +**How to avoid:** Re-run the existing deterministic ladder and compare against live Workers invocation CPU metrics. Report both; do not substitute one for the other. The existing script's p95 budget is 8 ms under the 10 ms free ceiling. [VERIFIED: routing/scripts/measure-route-cpu.mjs; CITED: Cloudflare limits] + +### Pitfall 9: Double-Counting Static Assets as Worker Requests + +**What goes wrong:** The budget report inflates Worker usage or expects a hit rate on requests that never invoke the Worker. + +**Why it happens:** Asset traffic and dynamic traffic are conflated. + +**How to avoid:** State that static asset requests are free and unlimited; measure Worker invocations separately. The repo's `run_worker_first` array limits Worker execution to `/api/*` and `/telegram/*`. [CITED: Cloudflare pricing; VERIFIED: bot/wrangler.jsonc] + +### Pitfall 10: Assuming a Cache Hit Rate Without Cache Headers + +**What goes wrong:** The budget record claims good cache behavior, but hashed assets retain the default `max-age=0, must-revalidate` and no `_headers` policy exists. [CITED: Cloudflare static-assets headers docs] + +**Why it happens:** Cache headers are inferred rather than inspected. + +**How to avoid:** Add/verify `web/public/_headers`: long immutable caching for Vite hashed `/assets/*`, no-cache/revalidation for HTML, then inspect live response headers and `CF-Cache-Status`. Treat `CF-Cache-Status` as probabilistic per Cloudflare's own footnote. + +### Pitfall 11: Turning a Measured Battery Percentage into a Synthetic Claim + +**What goes wrong:** A single short or interrupted run is extrapolated into “<10%/hr.” + +**Why it happens:** The target feels like a unit test rather than an observation. + +**How to avoid:** Record device/OS, brightness, network, screen-on interval, start/end percentages, route length, and caveats. Calculate elapsed-hour rate from actual minutes. If the run is under 30 minutes or interrupted, mark it NOT RUN and keep the gate open. [CITED: D-04] + +### Pitfall 12: Closing the Phase With Unverified Hardware Legs + +**What goes wrong:** A browser replay is treated as proof of an iPhone or Desktop WebView behavior. + +**Why it happens:** The launch is time-boxed and the checklist needs a signature. + +**How to avoid:** Preserve the Phase 4/6 gate honesty protocol. Record PASS/FAIL/NOT RUN with client version and observations; the milestone completes only when the human signs. [VERIFIED: existing gate files; D-09] + +## Code Examples + +Verified patterns from official sources: + +### Telegram Native Fullscreen and Orientation Guard + +```typescript +// Source: https://core.telegram.org/bots/webapps +const supported = + wa !== null && + typeof wa.isVersionAtLeast === "function" && + wa.isVersionAtLeast("8.0") && + typeof wa.requestFullscreen === "function"; + +if (supported) { + wa.requestFullscreen?.(); + // lockOrientation locks the CURRENT mode; it is not a force-landscape call. + wa.lockOrientation?.(); +} +``` + +### Safe-Area-Aware Edge Padding + +```css +/* Source: https://webkit.org/blog/7929/designing-websites-for-iphone-x/ */ +.guide-banner { + padding-left: 12px; + padding-left: max(12px, env(safe-area-inset-left, 0px)); + padding-right: 12px; + padding-right: max(12px, env(safe-area-inset-right, 0px)); +} +``` + +### Wrangler KV Listing Probe + +```bash +# Source: https://developers.cloudflare.com/kv/reference/kv-commands/ +npx wrangler kv key list \ + --namespace-id=59f58a54bc44483e89872b20a005a5eb \ + --prefix="guidance:" +``` + +The list response includes each key's `name`, optional absolute `expiration`, optional metadata, `list_complete`, and cursor. [CITED: https://developers.cloudflare.com/kv/api/list-keys] + +### Static Asset Cache Rules + +```text +# Source: https://developers.cloudflare.com/workers/static-assets/headers/ +/assets/* + Cache-Control: public, max-age=31556952, immutable + +/ + Cache-Control: public, max-age=0, must-revalidate +``` + +### Existing Offline Golden-Route Gate + +```bash +# Source: repository routing/scripts/route-corpus.mjs +npm run routing:corpus +npm run routing:test +npm run search:corpus +npm run search:test +node routing/scripts/measure-route-cpu.mjs +``` + +## State of the Art + +| Old Approach | Current Approach | When Changed | Impact | +|--------------|------------------|--------------|--------| +| Telegram Mini App expanded height only | Native Mini App fullscreen, safe-area fields/events, orientation lock | Bot API 8.0, 2024-11-17 | PX-04 can use a supported product API; older clients need fallback. [CITED: Telegram docs] | +| Assume `Element.requestFullscreen()` is universally available | Capability detection plus Telegram-native path; DOM API is limited availability | Ongoing platform divergence | Avoids iOS/WebView activation and policy failures. [CITED: MDN] | +| One boot-time safe-area read | Dynamic safe/content safe-area events and re-resolution | Bot API 8.0 | Fullscreen and orientation changes can update insets. [CITED: Telegram docs] | +| Manual asset cache guesses | Workers static assets default `max-age=0, must-revalidate`, overridable with `_headers` | Current Workers static assets | Budget audit should inspect real headers, not assume caching. [CITED: Cloudflare docs] | +| Privacy proof via unit tests only | Source + bundle + live KV/expiry + signed written record | Phase 7 decision D-01 | End-to-end PRIV-02/GEO-03 evidence for forks. | + +**Deprecated/outdated:** +- `wrangler kv:key list` colon syntax is deprecated; current docs use `wrangler kv key list`. [CITED: Cloudflare KV commands] +- Battery Historian is no longer actively maintained; it can still corroborate an Android run, but simple phone battery readings are the required D-04 evidence. [CITED: Android Developers] +- `screen.orientation.lock()` should not be treated as the Android WebView orientation solution; Chromium reports WebView does not support it. [CITED: Chromium issue 40939281] + +## Assumptions Log + +| # | Claim | Section | Risk if Wrong | +|---|-------|---------|---------------| +| A1 | Telegram's native `requestFullscreen()` is preferable to the DOM Fullscreen API for every matrix client. | Architecture Patterns / Standard Stack | MEDIUM: iOS/Telegram client behavior is not fully described by official docs. Validate in the real-device walk; DOM fallback remains harmless. | +| A2 | Telegram `lockOrientation()` is useful but locks the current orientation rather than forcing landscape. | Pitfalls / Architecture | MEDIUM: The official text says “current mode”; keep CSS landscape support as the source of truth regardless. | +| A3 | The current shell can adapt to landscape with CSS-only changes without changing the fixed-position model. | Landscape Pattern | MEDIUM: Telegram viewport/fullscreen behavior may require `dvh`; capture first, change only on a proven failure. | +| A4 | The production bundle's only runtime fetch hosts are OpenFreeMap tiles, Telegram API, and the app's own Worker. | Privacy Audit | MEDIUM: MapLibre/telemetry endpoints and Telegram redirect URLs can vary. Produce the actual host inventory before signing. | +| A5 | The live KV namespace contains only `session:`, `chat:`, `loc:`, and `guidance:` key families, all with expiry. | Privacy Audit / KV Probe | MEDIUM: stale/manual keys may exist. The probe must report unexpected families rather than assume absence. | +| A6 | The current A* measurement can pass the 8 ms p95 gate against the checked-in graph. | Budget Audit | MEDIUM: the Phase 6 file records historical failures and mitigation; the current live graph may differ. Re-run and report, never assume. | +| A7 | A single Android run can produce a defensible <10%/hr observation. | Battery Method | MEDIUM: battery percentage granularity, background activity, and route conditions can dominate. Record caveats; do not synthesize. | +| A8 | Adding `_headers` for hashed assets and HTML will preserve Worker-free static delivery. | Budget / Cache Headers | LOW: documented Workers static-assets behavior; verify live headers. | +| A9 | The existing custom test runners can host the new audit and landscape vectors without a new framework. | Validation Architecture | LOW: established repo convention. | +| A10 | Phase 3 A1/A3 stale-fix assumption notes are locatable and closable/re-pointable in this phase. | Deferred Debt | LOW: locate during implementation; D-07 is explicit. | + +## Open Questions (RESOLVED) + +*All five questions below were CLOSED by the Phase 7 plans. Each item carries +its adopted disposition. The recommendations were adopted verbatim — the +plans implement them; none was silently dropped.* + +1. **Does Telegram iOS 8.0+ reliably emit fullscreen success/failure events to the Mini App?** + - What we know: The official API exposes `requestFullscreen`, `isFullscreen`, `fullscreenChanged`, and `fullscreenFailed`; the exact per-client reliability is not specified. [CITED: Telegram docs] + - What's unclear: iOS/Telegram Desktop event delivery and whether WebView-native fullscreen is used. + - Recommendation: Treat events as observability, not correctness. The UI must be usable without receiving either event; record each client's observed behavior in the gate. + - RESOLVED: **Adopted — events are observability, never correctness.** `web/src/telegram/fullscreen.ts` exposes `watchFullscreenEvents(wa, onChange)` with a no-op-safe, try/catch-wrapped handler that logs NOTHING; it is REGISTERED once at the guidance-start edge in `web/src/route/route.ts` (07-01 task 4) with a deliberately no-op callback. The UI never depends on either event. Per-client observed behaviour is recorded in the 07-06 device matrix (`07-LAUNCH-GATE.md` Part A). + +2. **What battery rate did the chosen Android phone actually record?** + - What we know: D-04 requires one >=30-minute screen-on run and no synthesis. + - What's unclear: Phone model, brightness, cellular/Wi-Fi, route conditions, and background apps. + - Recommendation: Use the exact checklist fields in `07-LAUNCH-GATE.md`; if unavailable, mark NOT RUN and hold the launch gate. + - RESOLVED: **Answered by measurement at the launch gate per D-04/D-06.** `07-LAUNCH-GATE.md` Part B carries the exact checklist field list (device/model, OS + client version, brightness, network, ≥30-minute screen-on interval, start %, end %, route description, elapsed minutes, computed %/hr, caveats); a shorter or interrupted run is recorded NOT RUN and the gate stays open (07-06 t1 + t2). No number is synthesized. + +3. **What is the live route CPU p95 now?** + - What we know: The script measures deterministic p95 with an 8 ms gate; Phase 6 recorded historical failures/mitigations. [VERIFIED: repo] + - What's unclear: Current live graph and Worker CPU metrics. + - Recommendation: Re-run the script and collect Workers Logs CPU data for the same route class; report both and do not label the budget green if either exceeds its budget. + - RESOLVED: **Answered by measurement at the launch gate per D-04/D-06.** The local ladder is RE-RUN as a child process by the `route-cpu` budget gate (07-04 t2) and its p95 is compared to the 8 ms budget against the 10 ms free-plan ceiling; the live Workers Logs CPU number is a SEPARATE measurement recorded in `docs/BUDGETS.md` §Statement — neither substitutes for the other, and the budget is not labelled green if either exceeds its budget (07-04 t2/t3, 07-06 Part C). + +4. **Which static assets are actually long-cacheable?** + - What we know: Vite emits hashed `/assets/*`; Workers defaults to revalidation and supports `_headers`. + - What's unclear: Whether the deployed build has a stable `_headers` file and whether live `CF-Cache-Status` is meaningful. + - Recommendation: Enumerate `web/dist`, add explicit rules, and verify live response headers. Treat `CF-Cache-Status` as probabilistic. + - RESOLVED: **Adopted — explicit rules + a live header probe that quotes, never interprets.** `web/public/_headers` carries exactly two rules (`/assets/*` immutable for 31556952 s, `/` `max-age=0, must-revalidate`) and is verified to reach `web/dist/` (07-04 t1); the `cache-headers` gate prints the OBSERVED live headers and quotes `CF-Cache-Status` as probabilistic (07-04 t2). + +5. **Does `/help` need an absolute GitHub URL or a repository-relative limitations link?** + - What we know: D-08 says `/help` gains one line pointing at the doc; D-09 requires the public GPL repository. + - What's unclear: Final public repository URL/name. + - Recommendation: Put the URL in deployment configuration or a single strings-module constant rather than scattering it; defer final copy to implementation. + - RESOLVED: **Adopted — a SINGLE strings-module constant per locale.** `bot/src/i18n/strings.ts` carries exactly one limitations pointer line per locale (`Known limits:` / `Had panduan:`) — the same URL in both, resolved at implementation from the repo's actual HTTPS remote (`git remote -v`) and never scattered across files (07-05 t2). + +## Environment Availability + +| Dependency | Required By | Available | Version | Fallback | +|------------|------------|-----------|---------|----------| +| Node.js + npm | All repo scripts, corpus gates, audit tools | ✗ in research shell | — | Use the maintainer's normal Node environment; version must be confirmed at execution start | +| Wrangler | Live KV probe, deploy/canary, Worker metrics | Unverified (`node` absent) | Manifest declares 4.151.3 | Install/use workspace dependency once Node is available | +| Cloudflare account/API credentials | Live KV and Worker audit | Unverified | — | Run source/bundle gates locally; mark live-probe gates NOT RUN and keep launch gate open | +| Real Android device | Fullscreen/landscape/battery | Human-owned per prior phases | — | NOT RUN; no synthetic pass | +| Real iOS device | Fullscreen/landscape | Phase 4 records iOS present | — | NOT RUN if unavailable | +| Telegram Desktop | Device matrix | Human-owned per prior phases | — | NOT RUN if unavailable | +| Plain browser | Device matrix + Playwright | Playwright declared in web workspace | 1.50.0 | Use browser leg and capture harness | +| ADB / Battery Historian | Optional battery corroboration | Unverified | — | D-04 only requires phone start/end readings; no fallback needed for the gate | + +**Missing dependencies with no fallback:** +- A Node/npm execution environment is required to run any repository gate. The planning environment currently reports `node: command not found`; execution must occur in the project’s configured Node environment. +- Human access to the selected Android phone and the real clients is required to sign SC1/SC4; the phase cannot substitute simulated evidence. + +**Missing dependencies with fallback:** +- Cloudflare live probes require credentials. Source/bundle gates and corpus gates can still run; live KV/expiry and cache-header checks remain mandatory before signing and cannot be replaced by local-only checks. +- Battery Historian is optional; D-04’s start/end phone readings are sufficient. + +## Validation Architecture + +### Test Framework + +| Property | Value | +|----------|-------| +| Framework | Repo-local Node test harnesses (`node:test`-style): bot/web/routing/search `test/runner.mjs`; Playwright for browser captures in `web` | +| Config file | None global — each workspace has `test/runner.mjs`; Web `vite.config.ts` | +| Quick run command | `npm run web:test && npm run bot:test` | +| Full suite command | `npm run bot:test && npm run web:test && npm run routing:test && npm run search:test && npm run search:corpus && npm run routing:corpus && node routing/scripts/measure-route-cpu.mjs` | +| Phase commands | `npm run privacy-audit && npm run budget-audit` (new) | +| Physical gate | `.planning/phases/07-hardening-private-launch/07-LAUNCH-GATE.md` — one Android+iOS+Desktop+browser walk plus one Android battery run | + +### Phase Requirements → Test Map + +| Req ID | Behavior | Test Type | Automated Command | File Exists? | +|--------|----------|-----------|-------------------|-------------| +| PX-04.a | Bot API <8.0 or absent method: guidance starts without fullscreen and remains usable | unit | `npm test -w web` | ❌ Wave 0 | +| PX-04.b | Bot API 8.0: `requestFullscreen()` called once at guidance start; failure is non-error | unit | `npm test -w web` | ❌ Wave 0 | +| PX-04.c | `fullscreenChanged` / `fullscreenFailed` update state without throwing | unit | `npm test -w web` | ❌ Wave 0 | +| PX-04.d | Safe-area inset changes after fullscreen/rotation are re-applied | unit | `npm test -w web` | ❌ Wave 0 | +| PX-04.e | Landscape viewport keeps banner, list, guidance card, and map usable without overlap/clipping | browser capture | `npm run capture -w web` | Existing harness; ❌ new landscape legs | +| PX-04.f | Fullscreen and landscape on Android/iOS/Desktop/browser | manual device | `07-LAUNCH-GATE.md` device matrix | ❌ Wave 0 gate file | +| PX-04.g | One Android guidance run >=30 min records battery and drains <10%/hr | manual measurement | `07-LAUNCH-GATE.md` battery record | ❌ Wave 0 gate file | +| SC2.a | Empty route options render designed pending card | unit/integration | `npm test -w web` | Existing route surface gates; confirm coverage | +| SC2.b | Route source error/timeout renders designed pending/no-route state | unit/integration | `npm test -w web` | ❌ explicit timeout/error vectors | +| SC2.c | Off-road grace/reroute/no-reroute states render calmly | unit/replay | `npm test -w web` | Existing guidance replay; confirm DOM coverage | +| SC2.d | No-geometry route is rejected/degraded, never half-drawn | unit | `npm test -w routing && npm test -w web` | Existing codec/route gates; confirm end-to-end vector | +| SC2.e | Older Telegram client without 8.0 methods behaves sanely | unit + manual | `npm test -w web`; gate file | ❌ new old-client vector | +| SC3.a | No banned persistence API in any production `src` tree | source gate | `npm run privacy-audit` | ❌ new tool; extends `web/test/no-storage.test.mjs` and bot privacy gates | +| SC3.b | Every Worker KV write carries TTL; cron has zero writes | source + runtime | `npm run privacy-audit` | ❌ new tool; extends bot privacy/cron gates | +| SC3.c | Live KV keys are TTL-expiring and only expected families exist | live probe | `npm run privacy-audit -- --live` | ❌ new probe | +| SC3.d | Logs contain category-only vocabulary; no coordinates/chat ids/payloads | source + bundle | `npm run privacy-audit` | ❌ new tool; extends category-only law | +| SC3.e | Production bundle contains no analytics/beacon and only approved fetch hosts | bundle gate | `npm run privacy-audit` | ❌ new tool | +| SC4.a | Golden route corpus green | CLI + suite | `npm run routing:corpus && npm test -w routing` | ✅ existing | +| SC4.b | Search golden corpus green | CLI + suite | `npm run search:corpus && npm test -w search` | ✅ existing | +| SC4.c | A* CPU p95 <=8 ms local; live Worker CPU <=10 ms | measurement | `node routing/scripts/measure-route-cpu.mjs`; live logs probe | ✅ local script; ❌ live probe | +| SC4.d | Production bundle and Worker bundle within recorded budgets | measurement | `npm run budget-audit` | ❌ new tool; BA9 precedent exists | +| SC4.e | KV writes by request class recorded; cron=0 | source + runtime | `npm run budget-audit` | ❌ new tool | +| SC4.f | Static assets have intended cache headers | live header probe | `npm run budget-audit -- --live` | ❌ new probe; no `_headers` found in repo | +| SC4.g | Known-limitations doc shipped and linked from `/help` en/ms | unit/doc gate | `npm test -w bot`; link check in audit | ❌ doc + one-liner | +| SC4.h | Deferred INFO debts fixed and exact-900s boundary pinned | unit | `npm test -w bot` | ❌ new boundary vector; IN-01/02 mechanical | + +### Sampling Rate +- **Per task commit:** `npm test -w web` or `npm test -w bot` for the touched workspace; run `npm run privacy-audit` when touching storage/log/network code. +- **Per wave merge:** full automated suite plus `npm run privacy-audit && npm run budget-audit`. +- **Phase gate:** Full suite green, both audits green, live probes green, physical device matrix complete, battery run recorded, limitations doc shipped, checklist signed. + +### Wave 0 Gaps +- [ ] `web/test/fullscreen.test.mjs` — covers PX-04.a–d and old-client fallback. +- [ ] `web/test/failure-state-matrix.test.mjs` — covers SC2 named vectors and DOM state tokens. +- [ ] `scripts/privacy-audit.mjs` + `scripts/lib/*` — consolidated source/bundle/live audit. +- [ ] `scripts/budget-audit.mjs` + `scripts/lib/*` — consolidated budget record and live probes. +- [ ] `web/public/_headers` — hashed-asset immutable caching + HTML revalidation policy, if verified by audit. +- [ ] `bot/test/initdata.test.mjs` exact-900s vector — D-07 IN-03. +- [ ] `docs/KNOWN-LIMITATIONS.md` — D-08. +- [ ] `.planning/phases/07-hardening-private-launch/07-LAUNCH-GATE.md` — device matrix, battery method/result, checklist signature. +- [ ] Framework install: none — use the existing custom runners. + +## Security Domain + +### Applicable ASVS Categories + +| ASVS Category | Applies | Standard Control | +|---------------|---------|-----------------| +| V2 Authentication | yes | Existing HMAC initData validation and one-time opaque session tokens; re-pin exact boundary. [VERIFIED: bot/src/security/initdata.ts] | +| V3 Session Management | yes | 900-second session TTL and rotation; live expiry probe. [VERIFIED: bot/src/kv/sessions.ts] | +| V4 Access Control | yes | Bearer claim-derived identity; chat/user ids never accepted from client bodies. [VERIFIED: bot/src/webhook.ts] | +| V5 Input Validation | yes | UUID/slug/body caps and typed snapshot validation; preserve during hardening. [VERIFIED: bot/src/webhook.ts] | +| V6 Cryptography | yes | Web Crypto HMAC; no new cryptography; exact-900 comparator boundary pinned. [VERIFIED: bot/src/security/initdata.ts] | + +### Known Threat Patterns for This Stack + +| Pattern | STRIDE | Standard Mitigation | +|---------|--------|---------------------| +| Coordinate leakage through logs | Information Disclosure | Category-only log vocabulary; source scan + runtime log review. | +| Persistent location/profile storage | Privacy / Information Disclosure | Memory-only client; TTL-only Worker KV; live key/expiry probe. | +| Telemetry beacon smuggled into bundle | Information Disclosure | Production bundle host/beacon allowlist scan. | +| Unapproved external runtime fetch | Information Disclosure / Tampering | Positive host allowlist: OpenFreeMap tiles, Telegram API, own Worker. | +| Cross-user envelope access | Elevation of Privilege | Claim-derived ownership and indistinguishable 404 responses. | +| Fullscreen request used as a crash path | Denial of Service | Capability guard, try/catch, `fullscreenFailed` fallback. | +| Orientation lock abuse/DoS | Denial of Service | Telegram-only lock; no forced orientation; reject is non-error. | +| Stale initData at exact boundary | Authentication bypass | Exact-900s inclusive-boundary test (D-07 IN-03). | +| Asset cache policy leak/staleness | Information Disclosure | Explicit `_headers`; HTML revalidation; hashed assets immutable. | + +## Sources + +### Primary (HIGH confidence) +- `https://core.telegram.org/bots/webapps` — Bot API 8.0 fullscreen/orientation/safe-area methods and events; version guard; current Bot API history. +- `https://telegram.org/blog/fullscreen-miniapps-and-more` — Mini Apps can use full screen in portrait or landscape; orientation locking announcement. +- `https://developer.mozilla.org/en-US/docs/Web/API/Element/requestFullscreen` — promise/rejection behavior, transient activation, permissions policy, limited availability. +- `https://developer.mozilla.org/en-US/docs/Web/API/ScreenOrientation/lock` — lock rejection types and limited availability. +- `https://webkit.org/blog/7929/designing-websites-for-iphone-x/` — `viewport-fit=cover`, `env(safe-area-inset-*)`, `max()` guidance. +- `https://developers.cloudflare.com/workers/platform/limits/` — current Free limits, CPU, subrequests, assets, logs. +- `https://developers.cloudflare.com/workers/platform/pricing/` — static assets free/unlimited; KV and Workers Free quotas. +- `https://developers.cloudflare.com/kv/platform/limits/` — KV Free reads/writes/list/storage and per-invocation operations. +- `https://developers.cloudflare.com/kv/reference/kv-commands/` — current Wrangler KV syntax and flags. +- `https://developers.cloudflare.com/kv/api/list-keys` — key expiration and pagination semantics. +- `https://developers.cloudflare.com/workers/static-assets/headers/` — default cache headers, `_headers`, `CF-Cache-Status` caveat. +- `https://developers.cloudflare.com/workers/observability/logs/workers-logs/` — invocation CPU/wall data and observability configuration. +- `https://developer.android.com/topic/performance/power/battery-historian` — battery measurement context; tool no longer actively maintained. +- Repository files listed in the phase context, especially `web/src/telegram/webapp.ts`, `web/src/telegram/safe-area.ts`, `web/src/shell.css`, `web/src/route/route.ts`, `web/src/guidance/guidance.ts`, `web/src/guidance/watch.ts`, `bot/src/webhook.ts`, `bot/test/route-privacy.test.mjs`, `web/test/no-storage.test.mjs`, `routing/scripts/measure-route-cpu.mjs`, routing/search corpus runners, and Phase 4/6 device gates. + +### Secondary (MEDIUM confidence) +- Chromium issue 40939281 — Android WebView does not support `screen.orientation.lock()` and now permits feature detection to report absence. +- MDN browser-compatibility data / Can I Use search results — iOS Safari fullscreen partial support and WKWebView divergence; still requires real-device verification in Telegram. +- Telegram bug report 66082 — Android 10 fullscreen Mini App safe-area/inset issue; use as a QA warning, not as a universal current limitation. + +### Tertiary (LOW confidence) +- General open-source navigation “known limitations” conventions — only section structure was synthesized from OSM/OsmAnd/GraphHopper documentation. The exact document wording remains a project choice. + +## Metadata + +**Confidence breakdown:** +- Standard stack: HIGH — repository manifests and official platform docs; registry freshness could not be queried because Node was unavailable in the research shell. +- Architecture: MEDIUM-HIGH — current code boundaries are verified; per-client fullscreen behavior still needs the physical walk. +- Pitfalls: HIGH for official platform constraints and repository conventions; MEDIUM for Telegram iOS/Desktop event reliability and battery interpretation. + +**Research date:** 2026-10-25 +**Valid until:** 2026-11-24 (platform APIs and Worker limits are current as of research; re-check Telegram client notes and Cloudflare limits before signing the launch gate) diff --git a/.planning/phases/07-hardening-private-launch/07-VALIDATION.md b/.planning/phases/07-hardening-private-launch/07-VALIDATION.md index 0b5b86f..0794093 100644 --- a/.planning/phases/07-hardening-private-launch/07-VALIDATION.md +++ b/.planning/phases/07-hardening-private-launch/07-VALIDATION.md @@ -2,7 +2,7 @@ phase: 7 slug: hardening-private-launch status: draft -nyquist_compliant: false +nyquist_compliant: true wave_0_complete: false created: 2026-10-25 --- @@ -56,11 +56,12 @@ created: 2026-10-25 | 07-03 t2 | 07-03-PLAN.md | 1 | SC3.c | T-07-01 | Live KV keys TTL-expiring, only expected families exist | live probe | `npm run privacy-audit -- --live` | ❌ W0 | ⬜ pending | | 07-03 t1 | 07-03-PLAN.md | 1 | SC3.d | T-07-03 | Logs category-only; no coordinates/chat ids/payloads | source + bundle | `npm run privacy-audit` | ❌ W0 | ⬜ pending | | 07-03 t2 | 07-03-PLAN.md | 1 | SC3.e | T-07-04 | No analytics/beacon; only approved fetch hosts | bundle gate | `npm run privacy-audit` | ❌ W0 | ⬜ pending | -| 07-04 t3 | 07-04-PLAN.md | 2 | SC4.a | — | Golden route corpus green | CLI + suite | `npm run routing:corpus && npm test -w routing` | ✅ existing | ⬜ pending | -| 07-04 t3 | 07-04-PLAN.md | 2 | SC4.b | — | Search golden corpus green | CLI + suite | `npm run search:corpus && npm test -w search` | ✅ existing | ⬜ pending | +| 07-04 t2 | 07-04-PLAN.md | 2 | SC4.a | — | Golden route corpus green (re-run by the budget audit's `golden-corpus` gate) | CLI + suite | `npm run budget-audit` (runs `routing:corpus`); `npm run routing:corpus && npm test -w routing` | ✅ existing | ⬜ pending | +| 07-04 t2 | 07-04-PLAN.md | 2 | SC4.b | — | Search golden corpus green (re-run by the budget audit's `golden-corpus` gate) | CLI + suite | `npm run budget-audit` (runs `search:corpus`); `npm run search:corpus && npm test -w search` | ✅ existing | ⬜ pending | | 07-04 t2 | 07-04-PLAN.md | 2 | SC4.c | — | A* CPU p95 ≤8 ms local; live Worker CPU ≤10 ms | measurement | `node routing/scripts/measure-route-cpu.mjs`; live probe | ✅ local; ❌ live probe | ⬜ pending | | 07-04 t2 | 07-04-PLAN.md | 2 | SC4.d | — | Bundle/Worker sizes within recorded budgets | measurement | `npm run budget-audit` | ❌ W0 (`scripts/budget-audit.mjs`) | ⬜ pending | | 07-04 t2 | 07-04-PLAN.md | 2 | SC4.e | T-07-18 | KV writes by request class recorded; cron=0 | source + runtime | `npm run budget-audit` | ❌ W0 | ⬜ pending | +| 07-04 t2 | 07-04-PLAN.md | 2 | SC4.i | T-07-23b | Subrequests per request class recorded; `/api/route` = 0 external; the cron tick's Telegram send is the only recurring class; unmeasurable rows read `not measured` | source + record | `npm run budget-audit` | ❌ W0 | ⬜ pending | | 07-04 t1 | 07-04-PLAN.md | 2 | SC4.f | T-07-20 | Static assets have intended cache headers | live header probe | `npm run budget-audit -- --live` | ❌ W0 | ⬜ pending | | 07-05 t2 | 07-05-PLAN.md | 1 | SC4.g | — | Known-limitations doc shipped, linked from `/help` en/ms | unit/doc gate | `npm test -w bot` | ❌ W0 | ⬜ pending | | 07-05 t1 | 07-05-PLAN.md | 1 | SC4.h | T-07-08 | INFO debts fixed; exact-900s boundary pinned | unit | `npm test -w bot` | ❌ W0 (`bot/test/initdata.test.mjs` 900s vector) | ⬜ pending | @@ -76,7 +77,7 @@ created: 2026-10-25 - [ ] `web/test/fullscreen.test.mjs` — covers PX-04.a–d and old-client fallback → **07-01 t3** (FS1–FS9) - [ ] `web/test/failure-state-matrix.test.mjs` — covers SC2 named vectors and DOM state tokens → **07-02 t2** (FM1–FM7) - [ ] `scripts/privacy-audit.mjs` + `scripts/lib/*` — consolidated source/bundle/live audit → **07-03 t1–t3** -- [ ] `scripts/budget-audit.mjs` + `scripts/lib/*` — consolidated budget record and live probes → **07-04 t2–t3** +- [ ] `scripts/budget-audit.mjs` + `scripts/lib/*` — consolidated budget record (CPU ladder, bundle bytes, KV write classes, subrequest inventory, golden corpora) and live probes → **07-04 t2–t3** - [ ] `web/public/_headers` — hashed-asset immutable caching + HTML revalidation policy, if verified by audit → **07-04 t1** - [ ] `bot/test/initdata.test.mjs` exact-900s vector — D-07 IN-03 → **07-05 t1** (T4b) - [ ] `docs/KNOWN-LIMITATIONS.md` — D-08 → **07-05 t2** @@ -96,11 +97,11 @@ created: 2026-10-25 ## Validation Sign-Off -- [ ] All tasks have `` verify or Wave 0 dependencies -- [ ] Sampling continuity: no 3 consecutive tasks without automated verify -- [ ] Wave 0 covers all MISSING references -- [ ] No watch-mode flags -- [ ] Feedback latency < 90 s -- [ ] `nyquist_compliant: true` set in frontmatter +- [x] All tasks have `` verify or Wave 0 dependencies +- [x] Sampling continuity: no 3 consecutive tasks without automated verify +- [x] Wave 0 covers all MISSING references +- [x] No watch-mode flags +- [x] Feedback latency < 90 s +- [x] `nyquist_compliant: true` set in frontmatter -**Approval:** pending +**Approval:** approved 2026-10-25