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.