docs(07): plan phase 07 — hardening + private launch (6 plans, 3 waves)

This commit is contained in:
araharah-executor
2026-10-25 02:31:50 +08:00
parent 0d437db10c
commit 27b340fa6b
7 changed files with 3106 additions and 0 deletions
@@ -0,0 +1,530 @@
---
phase: 07-hardening-private-launch
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- web/src/telegram/fullscreen.ts
- web/src/telegram/webapp.ts
- web/src/telegram/safe-area.ts
- web/src/mock/telegram-mock.ts
- web/src/route/route.ts
- web/test/fullscreen.test.mjs
autonomous: true
requirements:
- PX-04
must_haves:
truths:
- "A driver on Telegram Bot API 8.0+ gets fullscreen requested exactly once when guidance starts, and guidance continues normally if the request is refused."
- "A driver on an older Telegram client (no 8.0 methods, or version < 8.0) still gets working guidance — the fullscreen call is a silent no-op, never an error surface."
- "When the device rotates or enters fullscreen, the safe-area insets are re-resolved and re-applied, so the cockpit never sits under a notch or a Telegram control."
- "Nothing about the fullscreen path logs a coordinate, a user id, or a route payload — the path is category-only."
artifacts:
- path: "web/src/telegram/fullscreen.ts"
provides: "Capability-guarded Telegram fullscreen + orientation-lock wrapper (requestGuidanceFullscreen / exitGuidanceFullscreen / watchFullscreenEvents)"
contains: "isVersionAtLeast(\"8.0\")"
- path: "web/src/telegram/webapp.ts"
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)"
contains: "safeAreaChanged"
- path: "web/test/fullscreen.test.mjs"
provides: "FS1..FS9 + old-client fallback vectors (PX-04.a-d, SC2.e)"
contains: "FS1"
key_links:
- from: "web/src/route/route.ts"
to: "web/src/telegram/fullscreen.ts"
via: "requestGuidanceFullscreen(deps.wa ?? null) at activateGuidance() — the same edge as engageCloseProtection"
pattern: "requestGuidanceFullscreen"
- from: "web/src/route/route.ts"
to: "web/src/telegram/fullscreen.ts"
via: "exitGuidanceFullscreen(deps.wa ?? null) at teardownGuidance() — the same edge as releaseCloseProtection"
pattern: "exitGuidanceFullscreen"
- from: "web/src/telegram/safe-area.ts"
to: "web/src/telegram/webapp.ts"
via: "wa.onEvent('safeAreaChanged'|'contentSafeAreaChanged') re-resolves resolveSafeInsets(wa) and re-applies"
pattern: "contentSafeAreaChanged"
- from: "web/src/mock/telegram-mock.ts"
to: "web/src/telegram/webapp.ts"
via: "the mock satisfies every new optional member (contract + fake + test together law)"
pattern: "requestFullscreen"
---
<objective>
Ship the fullscreen capability layer that PX-04 requires: a Telegram-native
`requestFullscreen()` call at the guidance-start edge behind the Bot API 8.0
capability guard, an honest non-error fallback on older clients, and a
safe-area re-resolution path so the cockpit survives rotation and fullscreen.
Purpose: PX-04's first half — "Guidance supports fullscreen" — is the platform
capability. D-03 locks the mechanism (Telegram's native API behind the 8.0
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
`web/test/fullscreen.test.mjs` (new — PX-04.a–d + the old-client fallback).
</objective>
<execution_context>
@$HOME/.config/opencode/get-shit-done/workflows/execute-plan.md
@$HOME/.config/opencode/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/07-hardening-private-launch/07-CONTEXT.md
@.planning/phases/07-hardening-private-launch/07-RESEARCH.md
@.planning/phases/07-hardening-private-launch/07-PATTERNS.md
<interfaces>
From `web/src/telegram/webapp.ts` (the contract to grow — OPTIONAL members only,
never changing an existing member's shape):
```typescript
export interface TelegramWebAppLike {
initData: string;
startParam?: string;
colorScheme?: "light" | "dark";
themeParams?: { header_bg_color?: string };
version?: string;
platform?: string;
safeAreaInset?: { top: number; bottom: number; left: number; right: number };
contentSafeAreaInset?: { top: number; bottom: number; left: number; right: number };
isVersionAtLeast(version: string): boolean;
close(): void;
addToHomeScreen?(): void;
checkHomeScreenStatus?(cb: (status: HomeScreenStatus) => void): void;
isActive?: boolean;
HapticFeedback?: { /* …unchanged… */ };
disableVerticalSwipes?: () => void;
enableVerticalSwipes?: () => void;
enableClosingConfirmation?: () => void;
disableClosingConfirmation?: () => void;
onEvent?(event: string, cb: () => void): void;
offEvent?(event: string, cb: () => void): void;
BackButton?: { /* …unchanged… */ };
LocationManager?: { /* …unchanged… */ };
}
```
From `web/src/telegram/homescreen.ts` (the capability-guard shape to copy
verbatim — BOTH halves + try/catch):
```typescript
export function wireHomeScreen(wa: TelegramWebAppLike, locale: Locale): void {
if (typeof wa.addToHomeScreen !== "function" || !wa.isVersionAtLeast("8.0")) return;
// …
wa.onEvent?.("homeScreenAdded", hide);
}
```
From `web/src/guidance/close-protection.ts` (the version-gated call shape):
```typescript
export function engageCloseProtection(wa: TelegramWebAppLike | null): void {
if (wa !== null && typeof wa.isVersionAtLeast === "function" && wa.isVersionAtLeast("7.7")) wa?.disableVerticalSwipes?.();
if (wa !== null && typeof wa.isVersionAtLeast === "function" && wa.isVersionAtLeast("6.2")) wa?.enableClosingConfirmation?.();
}
```
From `web/src/telegram/safe-area.ts` (the resolve/apply pair to extend):
```typescript
export interface SafeInsets { top: number; bottom: number; left: number; right: number; }
export function resolveSafeInsets(wa: TelegramWebAppLike | null): SafeInsets;
export function applySafeArea(insets: SafeInsets): void;
```
From `web/src/route/route.ts` (the TWO wiring edges — `activateGuidance()` at
line ~337 and `teardownGuidance()` at line ~474; the guidance-start edge must
be a SYNCHRONOUS user-action transition, never an async callback — RESEARCH
Pitfall 1):
```typescript
function activateGuidance(): void {
const g = guidance;
if (g === null || g.active) return;
g.active = true;
engageCloseProtection(deps.wa ?? null); // ← fullscreen rides THIS line
if (deps.shellRoot !== undefined) deps.shellRoot.dataset.guidance = "active";
// …
}
function teardownGuidance(reason: "arrival" | "abandon"): void {
// …
releaseCloseProtection(deps.wa ?? null); // ← exitFullscreen rides THIS line
// …
}
```
</interfaces>
<tasks>
<task type="auto">
<name>task 1: grow the TelegramWebAppLike contract with the 8.0-optional fullscreen members + build the capability wrapper</name>
<files>web/src/telegram/webapp.ts, web/src/telegram/fullscreen.ts</files>
<read_first>
- web/src/telegram/webapp.ts (the contract; grow it, never reshape it)
- web/src/telegram/homescreen.ts (the capability-guard + try/catch shape to copy)
- web/src/guidance/close-protection.ts (the version-gated call shape)
- .planning/phases/07-hardening-private-launch/07-PATTERNS.md §`web/src/telegram/fullscreen.ts`
- .planning/phases/07-hardening-private-launch/07-RESEARCH.md §Pattern 1 + §Pitfalls 1–3
</read_first>
<action>
Create `web/src/telegram/fullscreen.ts` — a NEW module (the whole file is new;
the header comment must name PX-04 and D-03). It exports EXACTLY these three
functions:
```typescript
export function requestGuidanceFullscreen(wa: TelegramWebAppLike | null): boolean;
export function exitGuidanceFullscreen(wa: TelegramWebAppLike | null): boolean;
export function watchFullscreenEvents(wa: TelegramWebAppLike | null, onChange: (isFullscreen: boolean) => void): void;
```
`requestGuidanceFullscreen(wa)`:
1. Returns `false` immediately when `wa === null` (the plain-browser case).
2. Returns `false` immediately when `typeof wa.requestFullscreen !== "function"`
(method-absent half of the guard).
3. Returns `false` immediately when `typeof wa.isVersionAtLeast !== "function"`
OR `!wa.isVersionAtLeast("8.0")` (version half of the guard — Bot API 8.0
floor, the same floor `homescreen.ts` and `safe-area.ts` already use).
4. Otherwise calls `wa.requestFullscreen()` inside a `try { … } catch { … }`
— a throw is CAUGHT and returns `false` (a refused request is a supported
degradation, never an error surface). It then calls
`wa.lockOrientation?.()` inside its own try/catch (Telegram's lock locks the
CURRENT orientation only — never force landscape; never use
`screen.orientation.lock`). The function returns `true` only when the
`requestFullscreen()` call itself did not throw.
5. It MUST NOT call `document.documentElement.requestFullscreen()` — the DOM
Fullscreen API is gesture-gated and limited-availability; it is NOT the
primary path. It MUST NOT call `screen.orientation.lock(...)`.
`exitGuidanceFullscreen(wa)`: the mirror — returns `false` when `wa === null`,
when `typeof wa.exitFullscreen !== "function"`, or when the 8.0 guard fails;
otherwise calls `wa.exitFullscreen?.()` in a try/catch and `wa.unlockOrientation?.()`
in its own try/catch, returning `true` when the call did not throw.
`watchFullscreenEvents(wa, onChange)`: when `wa` is non-null and
`typeof wa.onEvent === "function"`, register `wa.onEvent("fullscreenChanged", …)`
and `wa.onEvent("fullscreenFailed", …)`. On `fullscreenChanged`, call
`onChange(true)`; on `fullscreenFailed`, call `onChange(false)`. BOTH handlers
MUST be wrapped in try/catch so a throwing client callback can never break the
guidance pump. A `fullscreenFailed` is a CATEGORY event, never a user-facing
error. `watchFullscreenEvents` MUST NOT log anything — no coordinates, no
user ids, no payloads (the D-14 category-only law).
In `web/src/telegram/webapp.ts`, ADD these OPTIONAL members to
`TelegramWebAppLike` (do not change, reorder, or remove any existing member;
append them after `disableClosingConfirmation?` and before `onEvent?`):
```typescript
/** Bot API 8.0+ — request the Mini App's native fullscreen. Optional:
* absent on older clients; the 8.0 guard runs before every call. */
requestFullscreen?(): void;
/** Bot API 8.0+ — leave the Mini App's native fullscreen. Optional. */
exitFullscreen?(): void;
/** Bot API 8.0+ — whether the Mini App is currently fullscreen. Optional. */
isFullscreen?: boolean;
/** Bot API 8.0+ — lock the CURRENT orientation (never forces landscape). */
lockOrientation?(): void;
/** Bot API 8.0+ — release the orientation lock. */
unlockOrientation?(): void;
```
Also extend the module header comment (the paragraph that begins
` * Phase 4 (ONBD-02/ONBD-03) grows the contract by exactly three OPTIONAL`) with
a `Phase 7 (PX-04/D-03) adds the native-fullscreen members as OPTIONAL only:
requestFullscreen/exitFullscreen/isFullscreen/lockOrientation/unlockOrientation
(8.0+) — the contract stays the contract, no existing member changes shape,
every test fake and the harness mock satisfy exactly these members.` sentence.
</action>
<acceptance_criteria>
- `grep -c "requestFullscreen" web/src/telegram/webapp.ts` returns ≥ 1 and the file contains the literal `requestFullscreen?(): void;`
- `web/src/telegram/fullscreen.ts` exists and exports exactly `requestGuidanceFullscreen`, `exitGuidanceFullscreen`, `watchFullscreenEvents`
- `grep -c "isVersionAtLeast(\"8.0\")" web/src/telegram/fullscreen.ts` returns ≥ 1 (the version half of the guard)
- `grep -c "requestFullscreen" web/src/telegram/fullscreen.ts` returns ≥ 1 and `grep -c "exitFullscreen" web/src/telegram/fullscreen.ts` returns ≥ 1
- `grep -c "documentElement.requestFullscreen\|screen.orientation.lock" web/src/telegram/fullscreen.ts` returns 0 (the DOM-fullscreen and generic-orientation-lock anti-patterns are absent)
- `grep -c "catch" web/src/telegram/fullscreen.ts` returns ≥ 3 (the request, the exit, and the event handler each catch)
- `grep -c "console\." web/src/telegram/fullscreen.ts` returns 0 (category-only law: this path logs nothing at all)
</acceptance_criteria>
<verify>
<automated>export PATH="$HOME/.nvm/versions/node/v24.18.0/bin:$PATH"; cd /home/C-Fu/araharah && npm run web:typecheck 2>&1 | tail -20</automated>
</verify>
<done>
The contract carries the five 8.0-optional members; the wrapper exists with
the BOTH-halves guard, try/catch on every platform call, no DOM-fullscreen
and no generic orientation lock, and no logging.
</done>
</task>
<task type="auto">
<name>task 2: re-resolve safe-area insets on safeAreaChanged / contentSafeAreaChanged</name>
<files>web/src/telegram/safe-area.ts</files>
<read_first>
- web/src/telegram/safe-area.ts (the resolve/apply pair to extend)
- web/src/telegram/webapp.ts (the contract with the event members)
- .planning/phases/07-hardening-private-launch/07-RESEARCH.md §Pitfall 3 (Telegram's safeAreaInset in fullscreen)
</read_first>
<action>
Extend `web/src/telegram/safe-area.ts` with ONE new exported function — do not
change `resolveSafeInsets` or `applySafeArea`'s existing behaviour:
```typescript
export function watchSafeAreaChanges(wa: TelegramWebAppLike | null): void;
```
`watchSafeAreaChanges(wa)`:
1. Returns immediately when `wa === null` (plain browser) or
`typeof wa.onEvent !== "function"`.
2. Registers a handler for `"safeAreaChanged"` and one for
`"contentSafeAreaChanged"`. Each handler calls
`applySafeArea(resolveSafeInsets(wa))` — the SAME union logic, never a
duplicated inset computation (the "one authority" law). The handler is
wrapped in try/catch so a throwing client callback never breaks the boot.
3. It MUST NOT call `resolveSafeInsets` more than once per event and MUST NOT
write any storage surface.
Also extend the module header comment with one line naming PX-04/D-03 and the
reason: the current code computes the insets once at boot; fullscreen and
orientation changes can alter them, so the insets are re-resolved on
`safeAreaChanged` / `contentSafeAreaChanged` (RESEARCH Pitfall 3).
</action>
<acceptance_criteria>
- `grep -c "watchSafeAreaChanges" web/src/telegram/safe-area.ts` returns ≥ 1
- `grep -c "safeAreaChanged" web/src/telegram/safe-area.ts` returns ≥ 1 and `grep -c "contentSafeAreaChanged" web/src/telegram/safe-area.ts` returns ≥ 1
- `grep -c "resolveSafeInsets" web/src/telegram/safe-area.ts` returns ≥ 3 (definition + the two event handlers' call sites)
- `grep -c "localStorage\|sessionStorage\|indexedDB\|document.cookie" web/src/telegram/safe-area.ts` returns 0
</acceptance_criteria>
<verify>
<automated>export PATH="$HOME/.nvm/versions/node/v24.18.0/bin:$PATH"; cd /home/C-Fu/araharah && npm run web:typecheck 2>&1 | tail -20</automated>
</verify>
<done>
`watchSafeAreaChanges` exists, re-resolves through the existing union
authority on both Telegram inset events, catches handler throws, and writes
nothing to storage.
</done>
</task>
<task type="auto">
<name>task 3: mirror the new members in the harness mock + write the FS* test suite</name>
<files>web/src/mock/telegram-mock.ts, web/test/fullscreen.test.mjs</files>
<read_first>
- web/src/mock/telegram-mock.ts (the MockWebApp shape + the `__events` map + `versionAtLeast`)
- web/test/homescreen.test.mjs (the WHOLE file is the template: fakeWebApp + installDocumentStub + restoreDocument + the both-halves vectors + the closing source-scan ban)
- web/src/telegram/webapp.ts (the contract as grown in task 1)
- web/src/telegram/safe-area.ts (as extended in task 2)
- .planning/phases/07-hardening-private-launch/07-PATTERNS.md §`web/test/fullscreen.test.mjs`
</read_first>
<action>
In `web/src/mock/telegram-mock.ts`, add the five OPTIONAL members to the
`fake` object literal so the mock satisfies the grown contract (the
"contract + fake + test together" law — this change ships in the SAME commit
as the contract growth):
```typescript
requestFullscreen() {
fake.__fullscreenRequested = (fake.__fullscreenRequested ?? 0) + 1;
fake.__isFullscreen = true;
},
exitFullscreen() {
fake.__fullscreenExited = (fake.__fullscreenExited ?? 0) + 1;
fake.__isFullscreen = false;
},
lockOrientation() {
fake.__orientationLocked = (fake.__orientationLocked ?? 0) + 1;
},
unlockOrientation() {
fake.__orientationUnlocked = (fake.__orientationUnlocked ?? 0) + 1;
},
```
and add the three state fields to the fake's initial literal
(`__fullscreenRequested: 0, __fullscreenExited: 0, __orientationLocked: 0,
__orientationUnlocked: 0, __isFullscreen: false`). The mock's `version` comes
from `options.version` — a `?mock=` profile with `version` below 8.0 must
therefore produce the absent-behaviour path; do NOT hard-code 8.0.
Create `web/test/fullscreen.test.mjs` (NEW — the whole file is new). Follow the
`web/test/homescreen.test.mjs` template EXACTLY: `import { test } from
"./harness.mjs";`, a `fakeWebApp(opts)` builder with the segment-wise numeric
`isVersionAtLeast` comparator (copy the shape from homescreen.test.mjs lines
87–127), a `makeElement`/`installDocumentStub`/`restoreDocument` trio only if
the module touches the DOM (it does not — `fullscreen.ts` is DOM-free, so the
DOM stub is OPTIONAL; if you add one, restore it in a `finally` block).
The suite MUST contain these named cases (the `FS*` prefix follows the repo's
`HS*`/`GS*`/`RS*` convention):
- **FS1** — a client whose `requestFullscreen` method is ABSENT:
`requestGuidanceFullscreen(wa)` returns `false`, nothing throws, and the
method was never queried.
- **FS2** — an old client (`version: "7.9"`, so `isVersionAtLeast("8.0")` is
false) with `requestFullscreen` PRESENT: the function returns `false` and
`wa.__calls.requestFullscreen === 0` — the version guard ran BEFORE the call.
- **FS3** — Bot API 8.0+: `requestGuidanceFullscreen(wa)` returns `true` and
`wa.__calls.requestFullscreen === 1` (called exactly once).
- **FS4** — a THROWING `requestFullscreen` (the platform refuses): the call
does not throw out of the wrapper, the wrapper returns `false`, and the
guidance path continues (assert `assert.doesNotThrow(...)`).
- **FS5** — `exitGuidanceFullscreen` mirrors the guard: absent method → false,
old client → false, 8.0 client → the method is called exactly once.
- **FS6** — `watchFullscreenEvents`: a `fullscreenChanged` event calls the
callback with `true`; a `fullscreenFailed` event calls it with `false`;
neither throws; a THROWING client callback does not escape the handler.
- **FS7** — the OLD-CLIENT FALLBACK (SC2.e): a client with `version: "7.0"`
and NO fullscreen members — `requestGuidanceFullscreen` returns `false`,
`exitGuidanceFullscreen` returns `false`, `watchFullscreenEvents` does not
throw, and the client is still usable (no error surface).
- **FS8** — SAFE-AREA RE-RESOLUTION (PX-04.d): with a stub `wa` whose
`safeAreaInset` changes between two values, calling
`watchSafeAreaChanges(wa)` and then firing `"safeAreaChanged"` re-applies the
insets: assert `document.documentElement.style.getPropertyValue("--safe-top")`
changes from the first value to the second. The test must install a minimal
`globalThis.document` stub with a `documentElement.style` that records
`setProperty` calls, and delete it in a `finally` block.
- **FS9** — the closing SOURCE-SCAN ban (the HS9 shape): comment-stripped
scan of `web/src/telegram/fullscreen.ts` asserting the code contains NONE of
`documentElement.requestFullscreen`, `screen.orientation.lock`,
`webkitRequestFullscreen`, `mozRequestFullScreen`, `requestFullscreen(`-on-
`document`, `orientation.lock`. It MUST also assert the code contains
`isVersionAtLeast("8.0")` (the guard is not optional).
The scan must use the repo's exact stripper:
`src.replace(/\/\*[\s\S]*?\*\//g, " ").replace(/(^|[^:\\])\/\/[^\n]*/g, "$1")`.
Every case must `await import("../src/telegram/fullscreen.ts")` at the top of
the file (the repo's lazy-import shape, `const { requestGuidanceFullscreen, … } =
await import("../src/telegram/fullscreen.ts");`).
</action>
<acceptance_criteria>
- `web/test/fullscreen.test.mjs` exists and contains the literals `FS1`, `FS2`, `FS3`, `FS4`, `FS5`, `FS6`, `FS7`, `FS8`, `FS9`
- `grep -c "requestFullscreen" web/src/mock/telegram-mock.ts` returns ≥ 1 and `grep -c "exitFullscreen" web/src/mock/telegram-mock.ts` returns ≥ 1 and `grep -c "lockOrientation" web/src/mock/telegram-mock.ts` returns ≥ 1
- `grep -c "screen.orientation.lock\|documentElement.requestFullscreen" web/test/fullscreen.test.mjs` returns ≥ 1 (the ban list names them) and `grep -c "isVersionAtLeast(\"8.0\")" web/test/fullscreen.test.mjs` returns ≥ 1
- `npm test -w web` exits 0 and the runner's summary line contains `0 failed`
</acceptance_criteria>
<verify>
<automated>export PATH="$HOME/.nvm/versions/node/v24.18.0/bin:$PATH"; cd /home/C-Fu/araharah && npm test -w web 2>&1 | tail -25</automated>
</verify>
<done>
The mock satisfies the grown contract and the FS1–FS9 suite is green,
covering the both-halves guard, the throwing-platform-call path, the
old-client fallback, the fullscreen events, and the safe-area
re-resolution.
</done>
</task>
<task type="auto">
<name>task 4: wire the fullscreen call into the guidance-start and trip-end edges</name>
<files>web/src/route/route.ts</files>
<read_first>
- web/src/route/route.ts (the activateGuidance / teardownGuidance edges — lines ~337 and ~474)
- web/src/telegram/fullscreen.ts (the wrapper, as built in task 1)
- .planning/phases/07-hardening-private-launch/07-RESEARCH.md §Pitfall 1 (the activation window)
</read_first>
<action>
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";`
2. In `activateGuidance()` — the SYNCHRONOUS guidance-start transition, the
same edge that already calls `engageCloseProtection(deps.wa ?? null);` —
add ONE line directly after it:
`requestGuidanceFullscreen(deps.wa ?? null);`
The return value is deliberately ignored: the fullscreen request is
BEST-EFFORT and MUST NEVER block or fail the guidance start. Do NOT await
it, do NOT branch on the result, and do NOT move the call into an async
callback (RESEARCH Pitfall 1 — the transient user-activation window is
exactly here).
3. In `teardownGuidance(reason)` — the same edge that calls
`releaseCloseProtection(deps.wa ?? null);` — add ONE line directly after it:
`exitGuidanceFullscreen(deps.wa ?? null);`
The trip-end teardown releases fullscreen alongside the close protection.
4. In `resume(trip)` — the `?g=` resumed-trip entry, which already calls
`engageCloseProtection(deps.wa ?? null);` — add ONE line directly after it:
`requestGuidanceFullscreen(deps.wa ?? null);`
(A resumed trip is a live guidance session; it gets the same fullscreen
treatment as a fresh start.)
5. In `stopGuidanceArtifacts()` — the walk-back teardown, which already calls
`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
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.`).
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.
</action>
<acceptance_criteria>
- `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 "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)
- `npm run web:typecheck` exits 0
</acceptance_criteria>
<verify>
<automated>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</automated>
</verify>
<done>
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.
</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| Telegram client → Mini App | The Telegram client owns the `requestFullscreen` / `lockOrientation` capability; the Mini App only asks. A client may refuse, lie about its version, or throw. |
| Mini App → DOM / CSS | The layout consumes safe-area insets reported by the client; a wrong inset clips the cockpit. |
## STRIDE Threat Register
| 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-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. |
</threat_model>
<verification>
Run after all tasks:
```bash
export PATH="$HOME/.nvm/versions/node/v24.18.0/bin:$PATH"
cd /home/C-Fu/araharah
npm test -w web
npm run web:typecheck
```
Both must exit 0. `npm test -w web` must report `0 failed`. The FS1–FS9 cases
must all appear as `PASS`.
</verification>
<success_criteria>
- `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.
- `web/test/fullscreen.test.mjs` carries FS1–FS9 and the whole `web` suite is green.
</success_criteria>
<output>
After completion, create `.planning/phases/07-hardening-private-launch/07-01-SUMMARY.md`
</output>
@@ -0,0 +1,493 @@
---
phase: 07-hardening-private-launch
plan: 02
type: execute
wave: 1
depends_on: []
files_modified:
- web/src/shell.css
- web/test/failure-state-matrix.test.mjs
- web/scripts/capture.mjs
autonomous: true
requirements:
- PX-04
must_haves:
truths:
- "In landscape (driving posture) the maneuver banner, the maneuver list, the guidance card and the map all stay visible and readable — nothing overlaps, nothing is clipped."
- "Every failure class — empty route, route error, route timeout, no geometry, off-road, location timeout, old client — renders a designed surface, never a blank screen."
- "A route whose geometry decodes to fewer than two points is refused before it can be half-drawn on the map."
- "The landscape rules change GEOMETRY ONLY — no new colour, no radius, no shadow, no blur; every declaration derives from a Rams token."
artifacts:
- path: "web/src/shell.css"
provides: "The @media (orientation: landscape) geometry block for the cockpit/banner/card"
contains: "@media (orientation: landscape)"
- path: "web/test/failure-state-matrix.test.mjs"
provides: "The FM1..FM7 named failure vectors with DOM state-token assertions"
contains: "FAILURE_VECTORS"
- path: "web/scripts/capture.mjs"
provides: "The landscape capture legs (PX-04.e) — a landscape cockpit shot plus the failure-state shots"
contains: "shell-guidance-landscape.png"
key_links:
- from: "web/src/shell.css"
to: "theme/src/tokens.css"
via: "every landscape declaration references var(--spacing-*)/var(--safe-*); the theme audit proves zero off-token values"
pattern: "var\\(--spacing-"
- from: "web/test/failure-state-matrix.test.mjs"
to: "web/src/route/route.ts"
via: "the route-null/throw path renders the designed pending card (asserted, not changed)"
pattern: "buildPendingCard"
- from: "web/scripts/capture.mjs"
to: "web/src/shell.css"
via: "the landscape leg opens a landscape viewport so the media query is exercised in a real browser"
pattern: "orientation: 'landscape'"
---
<objective>
Make the guidance cockpit usable in landscape (PX-04's second half) and prove
that every failure class renders a designed surface instead of a blank screen
(ROADMAP SC2).
Purpose: a navigation app's empty states ARE the polish surface, and a driver
holds the phone in landscape. D-03 locks the mechanism — landscape fixes are
CSS-only media queries with the Telemetry Rams tokens binding; the map stays
Rams-constant. RESEARCH Pattern 3 (failure classes as named vectors) is the
audit-coverage form: the route-null/off-road behaviours already exist and are
NOT redesigned here — they are pinned by tests.
Output: a landscape media-query block in `web/src/shell.css`,
`web/test/failure-state-matrix.test.mjs` (new — the FM1..FM7 named vectors),
and the landscape + failure capture legs in `web/scripts/capture.mjs`.
</objective>
<execution_context>
@$HOME/.config/opencode/get-shit-done/workflows/execute-plan.md
@$HOME/.config/opencode/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/phases/07-hardening-private-launch/07-CONTEXT.md
@.planning/phases/07-hardening-private-launch/07-RESEARCH.md
@.planning/phases/07-hardening-private-launch/07-PATTERNS.md
<interfaces>
From `web/src/shell.css` (the geometry the landscape block must adapt — the
sanctioned machined-geometry literals are: banner min-height 96px, arrow plate
48px + glyph 32px, compact strip 24px, row 48px + glyph 24px, next-row lamp
8px, panel max 320px/480px, bar 4px, 1px rules):
```css
.guide-banner {
position: absolute; top: calc(var(--safe-top, 0px) + var(--spacing-space-sm)); left: 0; right: 0;
min-height: 96px; padding: var(--spacing-space-sm) var(--spacing-space-md);
/* … */
}
[data-guidance="active"] .strip {
top: calc(var(--safe-top, 0px) + var(--spacing-space-sm) + 96px + 1px);
min-height: 24px;
}
.guide-list {
pointer-events: auto; position: relative; width: calc(100% - 2 * var(--spacing-margin));
max-width: 320px; max-height: 480px; overflow-y: auto;
margin-top: calc(var(--safe-top, 0px) + var(--spacing-space-sm) + 96px + 1px + 24px + var(--spacing-space-sm));
}
.route-stack[data-register="center"] { top: 50%; transform: translateY(-50%); }
.route-stack[data-register="bottom"] { top: auto; transform: none; bottom: calc(var(--safe-bottom, 0px) + 64px); }
```
From `web/src/route/route.ts` (the failure classes that ALREADY render — audit
coverage, not behaviour change):
```typescript
const result: RouteResult | null = await deps.source
.route({ origin: { lat: fix.lat, lng: fix.lng }, destinationId: destination.id })
.catch(() => null); // T-05-04: an exception renders pending — never a half-drawn line
if (result === null || result.options.length === 0) {
deps.showRouteCard(deps.buildPendingCard(deps.locale, { onSearchAgain: () => walkBack() }), "center");
setState("pending");
console.log("ARAHARAH-ROUTE state=pending");
return;
}
```
From `web/src/guidance/guidance.ts` (the off-road state machine — the named
constants the failure matrix pins):
```typescript
export const OFF_ROUTE_THRESHOLD_M = 50;
export const OFF_ROUTE_CONSECUTIVE_FIXES = 3;
export const OFF_ROUTE_GRACE_MS = 8000;
export const STORM_GUARD_MS = 20000;
export const MISSED_TURN_PAST_MANEUVER_M = 40;
export const MISSED_TURN_GRACE_MS = 3000;
export const ARRIVAL_THRESHOLD_M = 25;
export const GUIDANCE_START_ALONG_M = 30;
export function offRouteThresholdM(accuracyM: number | null): number;
```
From `web/src/guidance/banner.ts` (the DOM builders the failure matrix drives
through the minimal document fake — `buildGuidanceBanner`, `buildManeuverList`,
`buildGuidanceCard`, `buildConfirmCard`, `buildTripEndCard`, `MANEUVER_LIST_ID`).
From `web/scripts/capture.mjs` (the leg shape to extend):
```javascript
async function captureLeg({ name, query, colorScheme, grantGeo, waitMode, expectState, expectText, expectGuide, setup, interact }) {
const target = join(SHOTS, name);
const context = await browser.newContext({
viewport: { width: 390, height: 844 },
colorScheme,
deviceScaleFactor: 2,
});
// …
}
```
</interfaces>
<tasks>
<task type="auto">
<name>task 1: the landscape geometry block in shell.css (CSS-only, tokens binding)</name>
<files>web/src/shell.css</files>
<read_first>
- web/src/shell.css (the whole file — the token-var discipline and the `[data-guidance="active"]` state-attribute pattern)
- theme/src/tokens.css (the spacing/colour token names — the ONLY legal values)
- .planning/phases/07-hardening-private-launch/07-RESEARCH.md §Pattern 2 (Landscape as CSS Geometry Adaptation)
- .planning/phases/07-hardening-private-launch/07-RESEARCH.md §Pitfall 4 (100vh is not stable)
</read_first>
<action>
APPEND a new block at the END of `web/src/shell.css` (do not edit any
existing rule — the existing portrait geometry stays byte-identical):
```css
/* ————— Phase 7 landscape geometry (PX-04/D-03) —————
Landscape is an ADDITIONAL CONSTRAINT, never a separate screen: portrait
remains the default and every rule below only ADAPTS the cockpit/banner/
card geometry. The map cartography stays Rams-constant (D-10) — NO MapLibre
rule appears in this block. Every value is a token reference or a sanctioned
geometry literal; the map's cartography is untouched. */
@media (orientation: landscape) and (max-height: 560px) {
/* the banner band: shorter in landscape, and the horizontal safe insets
are honoured on both edges (max() with the token padding as the floor) */
.guide-banner {
min-height: 72px;
padding-left: max(var(--spacing-space-md), var(--safe-left, 0px));
padding-right: max(var(--spacing-space-md), var(--safe-right, 0px));
}
.guide-banner .guide-plate { width: 40px; height: 40px; }
.guide-banner .guide-plate svg { width: 28px; height: 28px; }
/* the compact strip rides directly under the shorter banner */
[data-guidance="active"] .strip {
top: calc(var(--safe-top, 0px) + var(--spacing-space-sm) + 72px + 1px);
}
/* the maneuver list: a landscape-height-bounded panel that clears the
banner and the strip instead of overlapping them */
.guide-list {
max-height: calc(100dvh - 104px - var(--safe-top, 0px) - var(--safe-bottom, 0px));
margin-top: calc(var(--safe-top, 0px) + var(--spacing-space-sm) + 72px + 1px + 24px + var(--spacing-space-sm));
}
/* the bottom register (the guidance card / the confirm card) sits inside
the landscape height instead of overflowing past it */
.route-stack[data-register="bottom"] {
bottom: calc(var(--safe-bottom, 0px) + var(--spacing-space-sm));
}
.route-stack[data-register="center"] { max-width: 320px; }
}
```
CONSTRAINTS — every one is binding:
1. **Tokens only.** Every colour/spacing/typography value is a `var(--…)`
reference. The ONLY literals permitted in this block are the machined
geometry numbers already sanctioned by the file's header comment (72px
banner min-height, 40px plate, 28px glyph, 104px chrome offset, 1px rule)
— and you MUST extend the header comment's sanctioned literal list with
exactly: `landscape banner min-height 72px, plate 40px + glyph 28px,
landscape chrome offset 104px`. The comment lists literals so a future
reader knows they are deliberate.
2. **Zero radius, zero shadow, zero blur, zero glass.** Do NOT write
`border-radius`, `box-shadow`, `filter: blur`, `backdrop-filter`, or any
`transition`/`animation` — the house style forbids them and the theme
audit flags them.
3. **No MapLibre rule.** No `#map` selector and no MapLibre class may appear
in this block (the map is Rams-constant, D-10/PX-02).
4. **No new markup.** No `data-*` attribute is introduced; the rules hang off
the EXISTING `.guide-banner` / `.guide-list` / `.route-stack` /
`[data-guidance="active"]` selectors.
5. **`100dvh` only where a viewport-relative height is genuinely needed** —
the `.guide-list` max-height. Do NOT convert the shell's `height: 100%` /
`position: fixed` sizing model (RESEARCH Pitfall 4: keep the current model
unless a failing leg proves a change).
After appending, run `npm run audit -w theme` (the DSGN-01 evidence command)
and confirm it still reports `AUDIT CLEAN`. If the audit flags a landscape
declaration, that declaration is off-token — replace the literal with the
matching token; never suppress the audit.
</action>
<acceptance_criteria>
- `grep -c "@media (orientation: landscape)" web/src/shell.css` returns exactly 1
- `grep -c "max-height: 560px" web/src/shell.css` returns exactly 1
- `grep -c "border-radius\|box-shadow\|backdrop-filter\|transition:\|animation:" web/src/shell.css` returns 0
- `grep -c "#map" web/src/shell.css` returns the SAME count as before this change (no MapLibre rule was added)
- `grep -c "landscape banner min-height 72px" web/src/shell.css` returns 1 (the header's sanctioned literal list was extended)
- `npm run audit -w theme` exits 0 and prints `AUDIT CLEAN`
</acceptance_criteria>
<verify>
<automated>export PATH="$HOME/.nvm/versions/node/v24.18.0/bin:$PATH"; cd /home/C-Fu/araharah && npm run audit -w theme 2>&1 | tail -8 && npm run web:typecheck 2>&1 | tail -5</automated>
</verify>
<done>
The landscape block exists, is CSS-only, uses tokens plus the extended
sanctioned literal list, has zero radius/shadow/blur/transition, touches no
MapLibre rule, and the theme audit stays clean.
</done>
</task>
<task type="auto">
<name>task 2: the FM1..FM7 failure-state matrix suite (named vectors, DOM state tokens)</name>
<files>web/test/failure-state-matrix.test.mjs</files>
<read_first>
- web/test/guidance-replay.test.mjs (the named-vector header + the pure-reducer replay shape — the WHOLE header is the template)
- web/test/guidance-surface.test.mjs (the minimal `makeEl` document fake + the DOM state-token assertion style)
- web/src/route/route.ts (the route-null/throw → pending card path)
- web/src/guidance/guidance.ts (the off-road state machine + its named constants)
- .planning/phases/07-hardening-private-launch/07-RESEARCH.md §Pattern 3 (Failure-State Matrix as Data)
</read_first>
<action>
Create `web/test/failure-state-matrix.test.mjs` (NEW — the whole file is new).
Follow the repo's conventions exactly: `import { strict as assert } from
"node:assert";`, `import { test } from "./harness.mjs";`, and the
`await import("../src/…")` lazy-import shape.
The file MUST declare a `FAILURE_VECTORS` array as DATA (RESEARCH Pattern 3's
data-driven form) with EXACTLY these seven entries and their `id`/`trigger`/
`expect` fields:
```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 the 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" },
{ id: "route-timeout", trigger: "route source never resolves within the budget", expect: "pending-card" },
];
```
Then write the named cases (the `FM*` prefix follows the repo's `HS*`/`GS*`/
`RS*` convention). Every case must be grep-verifiable by name:
- **FM1** — `FAILURE_VECTORS` has EXACTLY 7 entries and their `id`s are
exactly `["empty-route","route-error","no-geometry","off-road","location-timeout","old-client","route-timeout"]`.
- **FM2** — the EMPTY ROUTE renders the designed pending card, never a blank:
build the pending card through the REAL builder
(`buildRoutePendingCard` from `web/src/route/route-card.ts`, driven through
the minimal `makeEl` document fake copied from guidance-surface.test.mjs),
and assert the rendered element carries a non-empty `textContent` and that
no element in the built card has an empty class or an undefined dataset
state. Assert the card's text is NOT empty and NOT a bare "undefined".
- **FM3** — the ROUTE ERROR and the ROUTE TIMEOUT both land on the same
pending-card surface: assert that `deps.source.route` rejecting and
`deps.source.route` resolving with `options: []` both produce the pending
card. For the timeout vector, use a `Promise` that never settles and assert
the controller's state stays `idle`/`pending` (never `route`) and that
NOTHING is drawn — assert `setRouteDrawn` was never called (use the
injected `MapLike` stub the route-surface suite already uses, or a minimal
object with a recording `setData`). The suite MUST have a timeout guard
(e.g. `Promise.race` with a 200 ms timer) so the case cannot hang the
runner.
- **FM4** — the NO-GEOMETRY route is refused, never half-drawn: assert that a
polyline6 string decoding to fewer than 2 points is rejected. Use
`decodePolyline6` from `@araharah/routing/src/index.ts` and assert the
decoded point count is `< 2` for the vector, and that the route controller
treats such a geometry as null (the pending card), NOT as a drawable route.
- **FM5** — the OFF-ROAD class renders calmly: drive the PURE reducer
(`createGuidanceSession` + `stepGuidance` from
`web/src/guidance/guidance.ts`) with three fixes beyond the accuracy-aware
threshold and assert exactly ONE `reroute-requested` effect fires, that the
storm guard holds a second immediate excursion, and that the resulting
state is `rerouting` (not `off_route` hanging, not a throw). Assert the
banner's state for `rerouting` renders the CALM slot — build it with
`buildGuidanceBanner` and assert the rendered text contains no "REROUTE"
blame phrasing and that the card's text is non-empty.
- **FM6** — the OLD-CLIENT class (SC2.e): a `TelegramWebAppLike` fake with
`version: "7.0"` and NO 8.0 members — assert the guidance chrome still
builds (the banner builder produces a non-empty element) and that no
capability call is attempted. Do NOT import `web/src/telegram/fullscreen.ts`
here — plan 07-01 owns that module's suite; this case asserts the CHROME
survives an old client, not the fullscreen wrapper.
- **FM7** — the LOCATION-TIMEOUT class: assert the state's designed outcome
is one of the two designed surfaces (`denied` / `awaiting-pin`), never a
blank. Use the location chain's own state vocabulary from
`web/src/location/types.ts` (read the file to learn the exact state names)
and assert the failure-state set contains "denied" and "awaiting" as
members. If the location chain's exported type does not carry those names
verbatim, assert instead that the chain's failure branch returns a
NON-NULL state object whose `kind` is one of the two designed failure
kinds — read the source and pin the ACTUAL names.
NO case may assert a blank state, and no case may use a real network call, a
real DOM, or a timer longer than 200 ms. Every DOM-driving case must install
its `globalThis.document` stub and delete it in a `finally` block (the
homescreen.test.mjs law).
</action>
<acceptance_criteria>
- `web/test/failure-state-matrix.test.mjs` exists and contains the literals `FAILURE_VECTORS`, `FM1`, `FM2`, `FM3`, `FM4`, `FM5`, `FM6`, `FM7`
- `grep -c "id: \"empty-route\"\|id: \"route-error\"\|id: \"no-geometry\"\|id: \"off-road\"\|id: \"location-timeout\"\|id: \"old-client\"\|id: \"route-timeout\"" web/test/failure-state-matrix.test.mjs` returns exactly 7
- `grep -c "finally" web/test/failure-state-matrix.test.mjs` returns ≥ 1 (the DOM stub is restored)
- `npm test -w web` exits 0 and the runner's summary line contains `0 failed`
</acceptance_criteria>
<verify>
<automated>export PATH="$HOME/.nvm/versions/node/v24.18.0/bin:$PATH"; cd /home/C-Fu/araharah && npm test -w web 2>&1 | tail -25</automated>
</verify>
<done>
The failure matrix exists as DATA + named cases, covers empty/error/timeout/
no-geometry/off-road/old-client/location-timeout, asserts designed surfaces
rather than blanks, and the whole web suite is green.
</done>
</task>
<task type="auto">
<name>task 3: the landscape + failure-state capture legs</name>
<files>web/scripts/capture.mjs</files>
<read_first>
- web/scripts/capture.mjs (the `captureLeg` signature + the leg list + the guidance legs at lines ~800–1150)
- web/src/shell.css (the landscape block added in task 1)
- .planning/phases/07-hardening-private-launch/07-VALIDATION.md (PX-04.e — the landscape browser-capture requirement)
</read_first>
<action>
Extend `web/scripts/capture.mjs`:
1. `captureLeg` MUST accept ONE new optional parameter: `viewport`, defaulting
to `{ width: 390, height: 844 }`. Change the signature line to:
`async function captureLeg({ name, query, colorScheme, grantGeo, waitMode, expectState, expectText, expectGuide, setup, interact, viewport }) {`
and inside, replace the hard-coded `viewport: { width: 390, height: 844 },`
with `viewport: viewport ?? { width: 390, height: 844 },`.
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:
```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 },
});
```
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.
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
assertion. Use the existing pending-card leg's query/expectText values
verbatim (READ them from the file — the `shell-route-pending.png` leg
already exists) and add the landscape variant of it:
```javascript
/* ————— 07-02 — the landscape failure-state leg (SC2) —————
The pending card in landscape: the designed surface, never a blank map. */
await captureLeg({
name: 'shell-route-pending-landscape.png',
query: '<copy the shell-route-pending.png leg\'s query verbatim>',
colorScheme: 'light',
grantGeo: true,
waitMode: '<copy the shell-route-pending.png leg\'s waitMode verbatim>',
expectState: '<copy the shell-route-pending.png leg\'s expectState verbatim>',
expectText: '<copy the shell-route-pending.png leg\'s expectText verbatim>',
viewport: { width: 844, height: 390 },
});
```
4. Update the file's header comment block (the "Outputs (web/screenshots/ —"
list) with TWO new lines, matching the existing format:
` * shell-guidance-landscape.png the cockpit in landscape (driving posture) (07-02, PX-04)`
` * shell-route-pending-landscape.png the designed pending card in landscape (07-02, SC2)`
5. The capture harness's own MIN_BYTES blank bar already applies to every leg
(a landscape shot under 10240 bytes FAILS) — do NOT add a per-leg size
exemption. A landscape leg that renders a blank surface must FAIL the
capture run.
6. Do NOT change any existing leg's query, expectText, expectState, or
screenshot name. This plan ADDS legs; it never edits the existing ones.
</action>
<acceptance_criteria>
- `grep -c "viewport" web/scripts/capture.mjs` returns ≥ 3 (the signature, the default, the two legs)
- `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)
- `npm run capture -w web` exits 0 and the report lists BOTH new landscape shots as non-blank
</acceptance_criteria>
<verify>
<automated>export PATH="$HOME/.nvm/versions/node/v24.18.0/bin:$PATH"; cd /home/C-Fu/araharah && npm run capture -w web 2>&1 | tail -30</automated>
</verify>
<done>
The capture harness accepts a per-leg viewport, and the two new landscape
legs (the cockpit + the pending card) render non-blank shots in a real
browser — the landscape media query is exercised on film.
</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| Device orientation → layout | The client reports orientation; the layout must stay readable under both. |
| Routing/geometry source → client surface | Route geometry and route-source failures are untrusted inputs; a malformed polyline must not reach the map. |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-07-05 | Denial of Service | the failure classes — a blank screen is a dead app for a driver | mitigate | Every failure class is a NAMED vector in `FAILURE_VECTORS` and is asserted to render a designed surface (pending card / denied / awaiting) rather than an empty map. Pinned by FM2, FM3, FM5, FM7. |
| T-07-12 | Tampering | `web/src/shell.css` — a malformed polyline half-drawn on the map | mitigate | FM4 pins that a polyline6 decoding to `< 2` points is refused before the draw path; the pending card renders instead of a half-drawn line. |
| T-07-13 | Information Disclosure | the landscape/failure rendering path | accept | The cockpit renders route geometry and street names already on screen; no new data class is introduced and no coordinate is written to a log or storage surface by this plan. |
| T-07-14 | Tampering | `web/src/shell.css` — an off-token or blur/shadow declaration smuggled into the landscape block | mitigate | Every new declaration is a token reference or an extended sanctioned geometry literal; `npm run audit -w theme` (the DSGN-01 evidence command) must print `AUDIT CLEAN` — the zero-radius/no-shadow/no-blur house rule is enforced mechanically, not by review. |
| T-07-15 | Denial of Service | the capture harness — a landscape leg that renders blank | mitigate | The existing MIN_BYTES blank bar (10240 bytes) applies unchanged to the new legs; a blank landscape surface FAILS the capture run instead of passing silently. |
</threat_model>
<verification>
Run after all tasks:
```bash
export PATH="$HOME/.nvm/versions/node/v24.18.0/bin:$PATH"
cd /home/C-Fu/araharah
npm test -w web
npm run audit -w theme
npm run capture -w web
```
All three must exit 0. The capture run must report both new landscape shots as
non-blank, and the theme audit must print `AUDIT CLEAN`.
</verification>
<success_criteria>
- `web/src/shell.css` carries exactly one `@media (orientation: landscape) and (max-height: 560px)` block that adapts only the cockpit/banner/list/bottom-register geometry, uses tokens plus the extended sanctioned literal list, and contains zero radius/shadow/blur/transition declarations and zero MapLibre rules.
- `web/test/failure-state-matrix.test.mjs` carries `FAILURE_VECTORS` (exactly 7 named entries) and the FM1–FM7 cases; every failure class asserts a designed surface, never a blank.
- `web/scripts/capture.mjs` accepts a per-leg `viewport` and adds the two landscape legs (`shell-guidance-landscape.png`, `shell-route-pending-landscape.png`).
- `npm test -w web`, `npm run audit -w theme` and `npm run capture -w web` all exit 0.
</success_criteria>
<output>
After completion, create `.planning/phases/07-hardening-private-launch/07-02-SUMMARY.md`
</output>
@@ -0,0 +1,532 @@
---
phase: 07-hardening-private-launch
plan: 03
type: execute
wave: 1
depends_on: []
files_modified:
- scripts/privacy-audit.mjs
- scripts/lib/source-gates.mjs
- scripts/lib/bundle-gates.mjs
- scripts/lib/cloudflare-probes.mjs
- package.json
- docs/PRIVACY-AUDIT.md
autonomous: true
requirements:
- PX-04
must_have_notes:
- "This plan re-verifies PRIV-02 and GEO-03 end-to-end (D-01). PX-04 is claimed here because SC3 is a Phase-7 success criterion under the PX-04 phase."
must_haves:
truths:
- "No production source tree in the repo contains a persistence API outside the platform allowlist — the memory-only client law and the TTL-only Worker law, extended repo-wide."
- "Every Worker KV write carries an expirationTtl that references a NAMED constant — no TTL-free write path can ship."
- "No log statement can carry a coordinate, a chat id, or a route payload — the category-only vocabulary is machine-verified."
- "The production bundle contains no analytics, no beacon, and no fetch to any host outside the positive allowlist (OpenFreeMap tiles, the Telegram API, the app's own Worker)."
- "A live KV probe shows every live key family is one of session:/chat:/loc:/guidance: and every key carries an expiration."
- "The audit's exit codes distinguish PASS (0), FAIL (1) and NOT-RUN / missing environment (2) — a skipped live probe is never mistaken for a pass."
artifacts:
- path: "scripts/privacy-audit.mjs"
provides: "The consolidated audit CLI (npm run privacy-audit) — orchestration, evidence report, exit codes"
contains: "PRIVACY AUDIT"
- path: "scripts/lib/source-gates.mjs"
provides: "Comment-stripped source scans over ALL src trees — storage, TTL, log vocabulary, runtime fetch hosts"
contains: "stripComments"
- path: "scripts/lib/bundle-gates.mjs"
provides: "Production bundle scans — analytics/beacon tokens + the positive fetch-host allowlist"
contains: "ALLOWED_FETCH_HOSTS"
- path: "scripts/lib/cloudflare-probes.mjs"
provides: "The live KV listing probe (key families + expiration metadata) and the live header probe"
contains: "list_complete"
- path: "docs/PRIVACY-AUDIT.md"
provides: "The signed written audit record citing the evidence (D-01)"
contains: "PRIVACY AUDIT RECORD"
key_links:
- from: "scripts/privacy-audit.mjs"
to: "scripts/lib/source-gates.mjs"
via: "runStorageGate / runCoordinateLogGate / runRuntimeHostGate — each returns {id, pass, evidence}"
pattern: "source-gates\\.mjs"
- from: "scripts/privacy-audit.mjs"
to: "scripts/lib/bundle-gates.mjs"
via: "runBundleTelemetryGate — scans web/dist (PRODUCTION only)"
pattern: "bundle-gates\\.mjs"
- from: "scripts/privacy-audit.mjs"
to: "scripts/lib/cloudflare-probes.mjs"
via: "runKvExpiryProbe — only under the --live flag; missing credentials => exit 2"
pattern: "cloudflare-probes\\.mjs"
- from: "package.json"
to: "scripts/privacy-audit.mjs"
via: "the root script \"privacy-audit\": \"node scripts/privacy-audit.mjs\""
pattern: "privacy-audit"
---
<objective>
Ship the ONE consolidated privacy-audit tool the repo can run before every
fork's first driver, and the signed written audit record that cites its
evidence.
Purpose: D-01 — PRIV-02 and GEO-03 are re-verified END-TO-END, not asserted:
source scans over every `src` tree, a production-bundle scan for telemetry, a
live KV listing probe that reads the expiry metadata, and a signed record a
reviewer (or a fork) can rerun. D-02 — v1 carries ZERO telemetry, so there is
nothing to opt into; the audit proves the absence rather than auditing a
consent surface that does not exist.
Output: `scripts/privacy-audit.mjs` + `scripts/lib/{source-gates,bundle-gates,cloudflare-probes}.mjs`
(new), the root `privacy-audit` npm script, and `docs/PRIVACY-AUDIT.md` (the
signed record).
</objective>
<execution_context>
@$HOME/.config/opencode/get-shit-done/workflows/execute-plan.md
@$HOME/.config/opencode/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/phases/07-hardening-private-launch/07-CONTEXT.md
@.planning/phases/07-hardening-private-launch/07-RESEARCH.md
@.planning/phases/07-hardening-private-launch/07-PATTERNS.md
<interfaces>
The comment-stripped source scan — copy this EXACTLY from
`web/test/no-storage.test.mjs` lines 29–42 (the canonical twin also lives in
`bot/test/route-privacy.test.mjs` lines 42–59):
```javascript
function listFiles(dir, exts, acc = []) {
for (const entry of readdirSync(dir)) {
const full = path.join(dir, entry);
if (statSync(full).isDirectory()) listFiles(full, exts, acc);
else if (exts.some((e) => entry.endsWith(e))) acc.push(full);
}
return acc;
}
function stripComments(src) {
return src
.replace(/\/\*[\s\S]*?\*\//g, " ") // block comments
.replace(/(^|[^:\\])\/\/[^\n]*/g, "$1"); // line comments (not URLs inside strings — benign over-stripping, gate is prefer-fail reading)
}
```
The banned persistence vocabulary — from `web/test/no-storage.test.mjs` lines 22–25:
```javascript
const BANNED = [
"localStorage", "sessionStorage", "indexedDB", "caches", "CacheStorage",
"document.cookie", "WebApp.DeviceStorage", "WebApp.SecureStorage", "WebApp.CloudStorage",
];
```
The KV write-TTL law — from `bot/test/privacy.test.mjs` lines 32–49 (PG1):
```javascript
lines.forEach((line, i) => {
if (line.includes(".put(")) {
assert.ok(line.includes("expirationTtl"), `${file}:${i + 1} — KV write without expirationTtl (PRIV-02 gate): ${line.trim()}`);
const ttl = line.match(/expirationTtl:\s*([A-Za-z0-9_]+)/)?.[1];
assert.ok(ttl && /^[A-Z_]{2,}$/.test(ttl), `${file}:${i + 1} — expirationTtl must reference a named TTL constant, not an inline literal`);
}
});
```
The CLI exit-code contract — from `theme/scripts/audit.mjs` lines 20–24 and
797–814:
```text
* Exit codes (no silent passes — T-1-07):
* 0 clean — every checked declaration derives from tokens
* 1 violations — each printed as file:line: declaration
* 2 drift/error — token sheet degraded (see message)
```
The gate-orchestration shape — from 07-RESEARCH.md §Pattern 4:
```javascript
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);
```
The four KV key families and their TTL constants (from `bot/src/kv/*.ts`):
| Family | Prefix | TTL constant | TTL |
|--------|---------|--------------|-----|
| session | `session:` | `SESSION_TTL_SECONDS` | 900 |
| chat pointer | `chat:` | `SESSION_TTL_SECONDS` | 900 |
| location pin | `loc:` | `PIN_TTL_SECONDS` | 300 |
| guidance envelope | `guidance:` | `GUIDANCE_SESSION_TTL_SECONDS` | 7200 |
The category-only log vocabulary (from `bot/src/webhook.ts` lines 63–68) — every
`console.log` in `bot/src` has the shape `[surface] verdict: category`, where
`surface` is one of `webhook`, `initdata`, `location`, `location-fallback`,
`guidance`, `guidance-cron`, `route`, `search`, `search-alternate`.
The web-side state-token vocabulary (from `web/src/main.ts` and
`web/src/route/route.ts`) — every `console.log` in `web/src` is either a
`ARAHARAH-*` state token (`ARAHARAH-DEST state=…`, `ARAHARAH-LIVE fix=… acc=…`,
`ARAHARAH-ROUTE state=…`, `ARAHARAH-GUIDE state=…`, `ARAHARAH-DRIVE state=…`),
the `RAMS-BASEMAP-READY layers=…` signal, or a `console.error` carrying an
error object.
The positive runtime fetch-host allowlist (D-01) — the ONLY hosts the shipped
bundle may reach:
| Host | Why |
|------|-----|
| `tiles.openfreemap.org` | the basemap tiles / sprites / glyphs (`theme/basemap/liberty-rams.json`) |
| `telegram.org` | the official Telegram WebApp SDK script tag in `web/index.html` |
| the app's own Worker origin | `/api/session`, `/api/location`, `/api/location-fallback`, `/api/guidance` — a RELATIVE path, not a host |
</interfaces>
<tasks>
<task type="auto">
<name>task 1: the source-gates library — storage, TTL, log vocabulary, runtime fetch hosts (ALL src trees)</name>
<files>scripts/lib/source-gates.mjs</files>
<read_first>
- web/test/no-storage.test.mjs (the canonical comment-stripper + the BANNED list)
- bot/test/privacy.test.mjs (the PG1 same-line expirationTtl law)
- bot/test/route-privacy.test.mjs (the host-ban + import-allowlist style)
- .planning/phases/07-hardening-private-launch/07-PATTERNS.md §`scripts/privacy-audit.mjs` + §Shared Patterns
</read_first>
<action>
Create `scripts/lib/source-gates.mjs` — the repo-local source scanner (a
MAINTAINER-TIME REPO TOOL — nothing in `bot/src`, `web/src`, `routing/src`, or
`search/src` imports it; its header comment must say so, copying the law from
`routing/scripts/measure-route-cpu.mjs` lines 3–9).
It MUST export these named functions, each returning
`{ id, pass, evidence }` (the RESEARCH Pattern 4 shape):
```javascript
export function runStorageGate(); // id: "storage-api"
export function runKvTtlGate(); // id: "kv-ttl"
export function runLogVocabularyGate(); // id: "log-vocabulary"
export function runRuntimeHostGate(); // id: "runtime-hosts"
```
**`runStorageGate()`** — scans EVERY production source tree: `web/src`,
`bot/src`, `routing/src`, `search/src`, `theme/src`. For each `.ts`/`.tsx`/
`.js`/`.mjs`/`.html` file, strip comments with the EXACT stripper above, then
assert the file contains NONE of the `BANNED` persistence tokens. Also assert
no file contains `serviceWorker.register`, `navigator.serviceWorker`, or
`workbox`. The evidence string must name the file count scanned and every
violation as `relative-path: token`.
**`runKvTtlGate()`** — scans EVERY file under `bot/src` (and any other tree
containing `.put(`). For each line containing `.put(`, assert the SAME LINE
contains `expirationTtl` and that the captured value matches
`/^[A-Z_]{2,}$/` (a NAMED constant, never an inline literal). The evidence
string must name each violation as `relative-path:line — KV write without a
named expirationTtl`.
**`runLogVocabularyGate()`** — the category-only vocabulary. For every
`console.log(` call site in `bot/src` and `web/src`:
- In `bot/src`: the line (comments stripped) MUST match
`/console\.log\(\s*[`"']\[([a-z-]+)\][^`"']*`/i` — i.e. it starts with a
bracketed surface token. Additionally the line MUST NOT contain any of the
coordinate/identifier carriers: `lat`, `lng`, `chat_id`, `user_id`, `token`,
`initData`, `geometry`, `polyline`, `payload`. (Note: `chat_id`/`user_id`
must be checked as whole words so that `[location]` does not trip
`chat`.)
- In `web/src`: the line MUST match one of
`/ARAHARAH-(DEST|LIVE|ROUTE|GUIDE|DRIVE)\s+state=/`,
`/ARAHARAH-LIVE\s+fix=/`,
`/RAMS-BASEMAP-READY/`. A `console.error(` line is allowed (the basemap
error path). Any other `console.log` FAILS the gate.
- ALSO assert that NO file in `bot/src` or `web/src` contains a bare
`JSON.stringify(` inside a `console.log(` argument — a serialized payload is
a payload leak.
The evidence string lists each violation as `relative-path:line — <the line>`.
**`runRuntimeHostGate()`** — the POSITIVE host allowlist. Enumerate every
`fetch(` call site in `web/src` and `bot/src` and every absolute `https://`
URL in `web/src`, `bot/src`, `theme/src`, and `web/index.html`. For each
ABSOLUTE URL, its host MUST be in the allowlist
`["tiles.openfreemap.org", "telegram.org"]` (a relative path like `/api/guidance`
is NOT a host and is allowed — the app's own Worker). For each `fetch(` call
site, the first argument MUST be a relative path (starting with `/`) or a
template literal whose static prefix is relative — NEVER an absolute URL to an
unapproved host. The evidence string must print the ACTUAL host inventory it
found (this is the A4 assumption's evidence — the host list is produced, not
assumed).
IMPORTANT — the stripper's over-stripping is deliberate ("prefer-fail
reading"). Do NOT write a smarter stripper. The header comment must say so.
</action>
<acceptance_criteria>
- `scripts/lib/source-gates.mjs` exists and exports `runStorageGate`, `runKvTtlGate`, `runLogVocabularyGate`, `runRuntimeHostGate`
- `grep -c "stripComments" scripts/lib/source-gates.mjs` returns ≥ 2 (the definition + at least one use)
- `grep -c "expirationTtl" scripts/lib/source-gates.mjs` returns ≥ 1 and `grep -c "localStorage" scripts/lib/source-gates.mjs` returns ≥ 1
- `grep -c "console.log" scripts/lib/source-gates.mjs` returns ≥ 1 (the log-vocabulary gate exists)
- `grep -c "tiles.openfreemap.org" scripts/lib/source-gates.mjs` returns ≥ 1 (the positive allowlist names the tile host)
- `grep -c "web/src\|bot/src\|routing/src\|search/src" scripts/lib/source-gates.mjs` returns ≥ 4 (the scan is repo-wide, not web-only)
</acceptance_criteria>
<verify>
<automated>export PATH="$HOME/.nvm/versions/node/v24.18.0/bin:$PATH"; cd /home/C-Fu/araharah && node --check scripts/lib/source-gates.mjs && echo "syntax ok" && node -e "import('./scripts/lib/source-gates.mjs').then(m=>{for(const k of ['runStorageGate','runKvTtlGate','runLogVocabularyGate','runRuntimeHostGate']){const r=m[k]();console.log(r.id+': '+(r.pass?'PASS':'FAIL')+' — '+r.evidence.slice(0,200));}})"</automated>
</verify>
<done>
The four source gates run over every production tree, print their
evidence, and report PASS on the current tree.
</done>
</task>
<task type="auto">
<name>task 2: the bundle-gates library + the cloudflare-probes library</name>
<files>scripts/lib/bundle-gates.mjs, scripts/lib/cloudflare-probes.mjs</files>
<read_first>
- web/test/build-artifact.test.mjs (the DirEnt walk at lines 79–87, the PRODUCTION-only scope law at lines 133–147, the JS-assets-only crypto rule at lines 175–187, and the BA9 gzip measurement at lines 221–234)
- .planning/phases/07-hardening-private-launch/07-RESEARCH.md §Pitfalls 5, 6, 7, 10 + §Pattern 5
- .planning/phases/07-hardening-private-launch/07-PATTERNS.md §No Analog Found (the live-probe guidance)
</read_first>
<action>
Create `scripts/lib/bundle-gates.mjs`:
```javascript
export function runBundleTelemetryGate(); // id: "bundle-telemetry" — reads web/dist ONLY
```
- It walks `web/dist` recursively (the DirEnt walk from
`web/test/build-artifact.test.mjs` lines 79–87 — copy it).
- **PRODUCTION ONLY**: `web/dist` — NEVER `web/dist-harness` (BA5's law: a
gate pointed at the harness build inverts itself; the harness is
deliberately not clean).
- If `web/dist` does not exist, the gate returns `{ pass: false, evidence:
"web/dist does not exist — run \`npm run build -w theme && npm run build -w web\` first" }`
(never a silent pass).
- It scans every emitted `.js` file for the analytics/beacon token set:
`["google-analytics.com","googletagmanager.com","analytics.google","segment.io",
"mixpanel","sentry.io","amplitude","datadog","bugsnag","posthog","plausible.io",
"hotjar","intercom","beacon","navigator.sendBeacon","gtag(","fbq("]`.
(`beacon` is checked as `navigator.sendBeacon` — a bare `beacon` substring
would false-positive on unrelated words; use the explicit form.)
- It scans every emitted `.js` AND `.html` file for ABSOLUTE `https://` URLs
and asserts every such URL's host is in the allowlist
`["tiles.openfreemap.org", "telegram.org"]`. The evidence string PRINTS the
full host inventory it found.
- It scans the emitted `.js` assets (JS ONLY, never the HTML — the official
Telegram SDK script is external and carries its own crypto, BA8's law) for
the deep-link crypto tokens `["createHmac","crypto.subtle","sha256","HMAC("]`
— the D-11 law. (Note: `crypto.subtle` may legitimately appear in the
harness mock — but the mock never ships to `web/dist`; if the scan trips on
it, that IS a finding.)
- It scans the emitted `.js` assets for the storage tokens
`["localStorage","sessionStorage","indexedDB","document.cookie"]` — but with
the platform allowlist: the OFFICIAL Telegram SDK's own
`__telegram__initParams` sessionStorage key is written by the EXTERNAL
`telegram-web-app.js`, which is loaded as a `<script src>` and is NOT in
`web/dist` — so the shipped bundle should carry NO storage token at all. If
a storage token IS found in `web/dist`, it is a FAIL (evidence names the
file).
- The evidence string must name every violation as `relative-path — <token>`.
Create `scripts/lib/cloudflare-probes.mjs`:
```javascript
export function runKvExpiryProbe(opts); // id: "kv-expiry" — LIVE ONLY
export function runAssetHeaderProbe(opts); // id: "asset-headers" — LIVE ONLY
export function probeUnavailable(reason); // { id, pass: false, notRun: true, evidence }
```
- **The live probe needs credentials.** If `CLOUDFLARE_API_TOKEN` +
`CLOUDFLARE_ACCOUNT_ID` are not in the environment (or `npx wrangler` is
unavailable), the probe returns `probeUnavailable("missing Cloudflare credentials")`
— `pass: false`, `notRun: true`. The orchestrator turns a `notRun` result
into exit code 2 (environment drift), NEVER into a pass.
- `runKvExpiryProbe` shells out to
`npx wrangler kv key list --namespace-id=59f58a54bc44483e89872b20a005a5eb`
(the live namespace id from `bot/wrangler.jsonc` — read it from that file
at runtime rather than hard-coding it twice; the id lives in
`bot/wrangler.jsonc` under `kv_namespaces[0].id`). It parses the JSON
output for each key's `name`, the optional `expiration`, the optional
`metadata`, the `list_complete` flag and the `cursor` (Pitfall 7 — key
presence alone does not prove TTL-only). It asserts:
1. every key's family prefix is one of `session:`, `chat:`, `loc:`,
`guidance:` — an unexpected family is REPORTED (A5 says report, never
assume absence);
2. every listed key carries an `expiration`;
3. it reports the OLDEST and NEWEST expiry it saw.
It FOLLOWS THE CURSOR until `list_complete` is true.
- `runAssetHeaderProbe` shells out to
`curl -sSI <MINI_APP_URL or the deployed origin>` and reads the
`cache-control` and `cf-cache-status` headers for the HTML entry and for a
hashed `/assets/*` file. It reports the observed headers VERBATIM
(CF-Cache-Status is probabilistic per Cloudflare's own footnote — the
evidence prints it, it does not interpret it as a guarantee).
- Both probes must be safe to call with no credentials: they return
`probeUnavailable(...)` rather than throwing.
</action>
<acceptance_criteria>
- `scripts/lib/bundle-gates.mjs` exists and exports `runBundleTelemetryGate`; it contains the literal `web/dist` and the literal `dist-harness` (as the FORBIDDEN path, in a comment or an exclusion check)
- `grep -c "ALLOWED\|allowlist" scripts/lib/bundle-gates.mjs` returns ≥ 1 and `grep -c "tiles.openfreemap.org" scripts/lib/bundle-gates.mjs` returns ≥ 1
- `grep -c "navigator.sendBeacon" scripts/lib/bundle-gates.mjs` returns ≥ 1 (the beacon token)
- `scripts/lib/cloudflare-probes.mjs` exists and exports `runKvExpiryProbe`, `runAssetHeaderProbe`, `probeUnavailable`
- `grep -c "list_complete" scripts/lib/cloudflare-probes.mjs` returns ≥ 1 and `grep -c "expiration" scripts/lib/cloudflare-probes.mjs` returns ≥ 1
- `grep -c "kv key list" scripts/lib/cloudflare-probes.mjs` returns ≥ 1 (the current wrangler syntax — the deprecated `kv:key list` form is forbidden)
</acceptance_criteria>
<verify>
<automated>export PATH="$HOME/.nvm/versions/node/v24.18.0/bin:$PATH"; cd /home/C-Fu/araharah && node --check scripts/lib/bundle-gates.mjs && node --check scripts/lib/cloudflare-probes.mjs && node -e "import('./scripts/lib/cloudflare-probes.mjs').then(m=>{const r=m.probeUnavailable('missing Cloudflare credentials');console.log(JSON.stringify(r)); if(r.pass!==false||r.notRun!==true){console.error('probeUnavailable must be pass:false + notRun:true');process.exit(1);}})"</automated>
</verify>
<done>
The bundle gate scans `web/dist` only against the positive host allowlist
and the analytics/beacon token set; the live probes parse the expiry
metadata and the pagination cursor, and degrade to `notRun` (never a
false pass) without credentials.
</done>
</task>
<task type="auto">
<name>task 3: the privacy-audit CLI + the root npm script + the signed audit record</name>
<files>scripts/privacy-audit.mjs, package.json, docs/PRIVACY-AUDIT.md</files>
<read_first>
- theme/scripts/audit.mjs (the exit-code contract, lines 20–24, and the report shape, lines 797–814)
- routing/scripts/measure-route-cpu.mjs (the maintainer-time-tool header law, lines 3–9)
- docs/CONFIGURATION.md (the docs-dir clinical prose style — a leading "surfaces, by kind" table)
- .planning/phases/07-hardening-private-launch/07-CONTEXT.md (D-01 and D-02 verbatim)
</read_first>
<action>
Create `scripts/privacy-audit.mjs` — the orchestrator (RESEARCH Pattern 4's
shape). Its header comment must state: this is a MAINTAINER-TIME REPO TOOL,
not a runtime dependency; it is GPL-licensed and reusable by forks; v1 carries
ZERO telemetry (D-02) so the audit proves an ABSENCE.
Behaviour:
- Accepts an optional `--live` flag. Without it, the live probes are SKIPPED
and the run reports `kv-expiry: NOT RUN (pass --live)` — a skip is never
silently green.
- Runs, in order: `runStorageGate()`, `runKvTtlGate()`, `runLogVocabularyGate()`,
`runRuntimeHostGate()`, `runBundleTelemetryGate()`; with `--live`, also
`runKvExpiryProbe()` and `runAssetHeaderProbe()`.
- Prints ONE line per gate in the form
`<id>: PASS|FAIL|NOT RUN — <evidence>` (the Pattern 4 shape), then a
summary line:
`PRIVACY AUDIT CLEAN — <n> gate(s) passed, 0 violations` or
`PRIVACY AUDIT FAILED — <n> violation(s) across <m> gate(s)` or
`PRIVACY AUDIT NOT RUN — <reason> (environment drift; never a pass)`.
- EXIT CODES (the repo's no-silent-passes contract):
- `0` — every required gate PASSed.
- `1` — at least one gate FAILED (the violations are printed above).
- `2` — environment/credential drift (a required live probe could not run
because credentials or the build are absent) — this is NOT a pass and the
message says so.
- The script must NEVER print a coordinate, a chat id, a user id, a token, or
an initData payload — its output is category-only, exactly like the bot's
logs.
Add to the ROOT `package.json` `scripts` (next to the other root scripts):
```json
"privacy-audit": "node scripts/privacy-audit.mjs",
```
Create `docs/PRIVACY-AUDIT.md` — the SIGNED written audit record (D-01's
"signed written audit record citing the evidence"). Follow the
`docs/CONFIGURATION.md` clinical-prose style. It MUST contain:
1. A title `# Privacy audit record` and a dated front-matter block naming the
audit date, the commit SHA (`git rev-parse --short HEAD`), and the auditor.
2. A `## Scope` section naming what was audited (all five `src` trees, the
production bundle `web/dist`, the live KV namespace) and what was NOT
(the harness build `web/dist-harness`, the Telegram client's own internal
storage — the platform allowlist).
3. A `## Findings` table with one row per gate: `| Gate | Result | Evidence |`
where the Evidence column carries the ACTUAL printed evidence string from
the run (copy it from the audit output — never invent it).
4. A `## Storage inventory` table listing the four KV key families with
their TTL constants and TTLs, and stating that no other family exists and
that no coordinate or profile is persisted.
5. A `## Log vocabulary` section listing the allowed log surfaces and
stating the coordinate-free law.
6. A `## Runtime network` section listing the ACTUAL host inventory the audit
printed, stating that no analytics/beacon host exists, and stating
D-02's zero-telemetry posture explicitly.
7. A `## Statement` section: the PRIV-02 + GEO-03 re-verification claim in
one honest paragraph, naming the evidence that backs it.
8. A `## Signature` block at the end with a line for the maintainer's name
and a date line, both left as `________________` to be filled by the human
at the launch gate (the audit record is SIGNED BY THE HUMAN — the tool
produces the evidence, the human signs the claim).
FILL the Findings/Storage/Log/Network sections with the REAL run output. Run
`npm run privacy-audit` yourself, paste the printed evidence verbatim. If a
gate FAILS, fix the underlying code and re-run — do NOT soften the record.
</action>
<acceptance_criteria>
- `scripts/privacy-audit.mjs` exists and contains the literals `PRIVACY AUDIT CLEAN`, `PRIVACY AUDIT FAILED`, `PRIVACY AUDIT NOT RUN`
- `grep -c "process.exit" scripts/privacy-audit.mjs` returns ≥ 3 (the three exit codes)
- `grep -c "notRun" scripts/privacy-audit.mjs` returns ≥ 1 (a skipped probe is never a pass)
- `grep -c '"privacy-audit"' package.json` returns exactly 1
- `docs/PRIVACY-AUDIT.md` exists and contains the literals `PRIVACY AUDIT RECORD` (or the title `Privacy audit record`), `## Scope`, `## Findings`, `## Storage inventory`, `## Log vocabulary`, `## Runtime network`, `## Statement`, `## Signature`
- `npm run privacy-audit` exits 0 on the current tree and prints `PRIVACY AUDIT CLEAN`
</acceptance_criteria>
<verify>
<automated>export PATH="$HOME/.nvm/versions/node/v24.18.0/bin:$PATH"; cd /home/C-Fu/araharah && npm run privacy-audit 2>&1 | tail -20 && npm run privacy-audit >/dev/null 2>&1; echo "exit=$?"</automated>
</verify>
<done>
`npm run privacy-audit` runs the consolidated gates, prints per-gate
evidence, exits 0/1/2 correctly, and `docs/PRIVACY-AUDIT.md` carries the
signed record citing that run's real evidence.
</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| Source tree → audit scanner | The scanner reads untrusted repository text; a comment-stripping mistake could hide a call site behind a prose mention. |
| Production bundle → runtime network | The shipped JS decides which hosts the app actually reaches; the source tree does not prove the bundle. |
| Cloudflare account → audit probe | The live probe reads the real namespace; a missing credential must never read as "clean". |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-07-01 | Information Disclosure | `bot/src/kv/*` — a KV write without a TTL becomes a persistent per-user location history | mitigate | `runKvTtlGate` asserts EVERY `.put(` line carries `expirationTtl` referencing a NAMED `^[A-Z_]{2,}$` constant, across every tree — the PG1 law extended repo-wide (D-01). The live probe additionally asserts every listed key carries an `expiration` and reports the oldest/newest expiry (Pitfall 7). |
| T-07-02 | Information Disclosure | `web/src` — a persistence API would make the client a location-history store | mitigate | `runStorageGate` scans all five production trees for the full BANNED persistence list plus the service-worker family, with comments stripped (the canonical stripper, copied verbatim). The bundle gate re-checks the shipped `web/dist` for the same tokens. |
| T-07-03 | Information Disclosure | `bot/src` / `web/src` log call sites — a coordinate, chat id, or payload in a log line | mitigate | `runLogVocabularyGate` requires every `bot/src` log to open with a bracketed surface token and forbids `lat`/`lng`/`chat_id`/`user_id`/`token`/`initData`/`geometry`/`polyline`/`payload` on the line; every `web/src` log must be an `ARAHARAH-*` state token or the basemap signal. Serialized payloads inside a log are forbidden outright. |
| T-07-04 | Information Disclosure | the production bundle — a smuggled analytics/beacon host or an unapproved fetch | mitigate | `runBundleTelemetryGate` scans `web/dist` (production only) for the analytics/beacon token set AND enforces a POSITIVE host allowlist (`tiles.openfreemap.org`, `telegram.org`); the host inventory is printed as evidence, not assumed (A4). |
| T-07-16 | Spoofing | the audit's own verdict — a missing credential reported as clean | mitigate | The live probes return `{pass:false, notRun:true}` without credentials and the CLI maps a `notRun` result to exit code 2 with the message `PRIVACY AUDIT NOT RUN` — a skip is never a pass (RESEARCH Pattern 4, the repo's no-silent-passes contract). |
| T-07-17 | Tampering | the comment stripper — a prose mention tripping the gate, or a call site hiding behind one | accept | The repo's own stripper is copied VERBATIM with its deliberate over-stripping ("prefer-fail reading"): a false positive fails the gate (a loud failure), and a hidden call site is caught by the bundle scan and the live probe, which do not rely on comment stripping. |
</threat_model>
<verification>
Run after all tasks:
```bash
export PATH="$HOME/.nvm/versions/node/v24.18.0/bin:$PATH"
cd /home/C-Fu/araharah
npm run build -w theme && npm run build -w web
npm run privacy-audit
npm run privacy-audit -- --live
```
The first must exit 0 with `PRIVACY AUDIT CLEAN`. The second must either exit 0
(with the live probes' evidence printed) or exit 2 with
`PRIVACY AUDIT NOT RUN` when the credentials are absent — it must NEVER exit 0
while reporting a live probe as skipped.
</verification>
<success_criteria>
- `npm run privacy-audit` exists and runs the storage, KV-TTL, log-vocabulary, runtime-host and bundle-telemetry gates over ALL production trees plus `web/dist`, printing per-gate evidence.
- The exit codes are 0 / 1 / 2 with "NOT RUN" never counted as a pass.
- The live KV probe reads the expiry metadata and the pagination cursor, and the live header probe prints the observed cache headers verbatim.
- `docs/PRIVACY-AUDIT.md` carries the signed audit record with the real run evidence in its Findings/Storage/Log/Network sections and a human signature block.
</success_criteria>
<output>
After completion, create `.planning/phases/07-hardening-private-launch/07-03-SUMMARY.md`
</output>
@@ -0,0 +1,464 @@
---
phase: 07-hardening-private-launch
plan: 04
type: execute
wave: 2
depends_on:
- "07-03"
files_modified:
- scripts/budget-audit.mjs
- scripts/lib/budget-gates.mjs
- web/public/_headers
- package.json
- docs/BUDGETS.md
autonomous: true
requirements:
- PX-04
must_have_notes:
- "This plan produces the SC4 budget record (D-06), a Phase-7 success criterion delivered under the PX-04 phase."
must_haves:
truths:
- "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."
- "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:
- path: "scripts/budget-audit.mjs"
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"
contains: "P95_BUDGET_MS"
- path: "web/public/_headers"
provides: "The Workers static-assets cache policy for hashed assets and the HTML entry"
contains: "immutable"
- path: "docs/BUDGETS.md"
provides: "The checked-in current-vs-limit budget record (D-06)"
contains: "BUDGET RECORD"
key_links:
- from: "scripts/budget-audit.mjs"
to: "routing/scripts/measure-route-cpu.mjs"
via: "the CPU ladder is RE-RUN as a child process (never re-implemented) — its p95 line is parsed"
pattern: "measure-route-cpu"
- from: "scripts/budget-audit.mjs"
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: "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"
pattern: "/assets/\\*"
---
<objective>
Produce the ONE consolidated budget record — Worker CPU, subrequests, bundle
sizes, KV writes per request class, asset cache headers — each current number
set beside the documented Cloudflare free-plan ceiling.
Purpose: D-06 — the free-first mandate is only real if the numbers are
MEASURED and PRINTED, not assumed. The repo already has the measuring
instruments (the A* CPU ladder, the BA9 bundle budget, the golden route/search
corpora); this plan CONSOLIDATES them into one command and one checked-in
record, and adds the static-asset cache policy the record needs to reference.
Output: `scripts/budget-audit.mjs` + `scripts/lib/budget-gates.mjs` (new),
`web/public/_headers` (new), the root `budget-audit` npm script, and
`docs/BUDGETS.md` (the record).
</objective>
<execution_context>
@$HOME/.config/opencode/get-shit-done/workflows/execute-plan.md
@$HOME/.config/opencode/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/phases/07-hardening-private-launch/07-CONTEXT.md
@.planning/phases/07-hardening-private-launch/07-RESEARCH.md
@.planning/phases/07-hardening-private-launch/07-PATTERNS.md
<interfaces>
The measurement header law — from `routing/scripts/measure-route-cpu.mjs`
lines 3–9 (the budget audit inherits this shape):
```text
* A MAINTAINER-TIME REPO TOOL — NOT a runtime dependency; nothing in bot/src,
* web/src, or routing/src imports it. The free plan gives a Worker 10 ms of
* CPU per request (Error 1102 beyond it), so the A*-over-the-real-graph cost
* is MEASURED AND PRINTED, never hoped for ("budget gates print their
* measurement" — the BA9 precedent).
```
The print-then-gate shape — from `web/test/build-artifact.test.mjs` lines 221–234:
```javascript
const total = inputs.reduce((sum, f) => sum + gzipSync(readFileSync(f)).length, 0);
console.log(`BA9 route bundle budget: ${total} / 204800 gz bytes`);
assert.ok(total <= 204800, `route payload is ${total} gz bytes — over the 204800-byte (200 KiB) D-11 budget by ${total - 204800}.`);
```
The gate-fail message with the mitigation ladder — from
`routing/scripts/measure-route-cpu.mjs` lines 180–185:
```javascript
if (p95 > P95_BUDGET_MS) {
console.error(`GATE FAIL: p95 ${p95.toFixed(3)} ms exceeds the ${P95_BUDGET_MS} ms budget (A1) — apply the mitigation ladder: (a) … (b) … (c) …`);
process.exit(1);
}
console.log(`GATE PASS: p95 ${p95.toFixed(3)} ms <= ${P95_BUDGET_MS} ms budget — A1 retired for this graph`);
```
The BA9 input set — from `web/test/build-artifact.test.mjs` lines 204–209:
```javascript
const ROUTE_INPUT_DIRS = [
join(WEB, "..", "routing", "src"),
join(WEB, "src", "route"),
join(WEB, "src", "guidance"),
];
const ROUTE_FIXTURES_FILE = join(WEB, "..", "routing", "src", "data", "route-fixtures.json");
```
The KV write classes (from `bot/src/kv/*.ts` and `bot/src/webhook.ts`):
| Class | Write site | Key prefix | TTL constant | TTL |
|-------|-----------|------------|--------------|-----|
| session mint | `bot/src/kv/sessions.ts` `mintSession` | `session:` + `chat:` | `SESSION_TTL_SECONDS` | 900 |
| location pin | `bot/src/kv/location-pin.ts` | `loc:` | `PIN_TTL_SECONDS` | 300 |
| guidance envelope | `bot/src/kv/guidance-sessions.ts` `mintGuidanceSession` | `guidance:` | `GUIDANCE_SESSION_TTL_SECONDS` | 7200 |
| cron tick | `bot/src/webhook.ts` `runGuidanceCronTick` | — | — | **0 writes** (lists + reads + Telegram sends only) |
The free-plan ceilings (from 07-RESEARCH.md §Pattern 5 — cite this table in
the record):
| 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 |
The static-asset cache rules (from 07-RESEARCH.md §Code Examples — use
verbatim):
```text
/assets/*
Cache-Control: public, max-age=31556952, immutable
/
Cache-Control: public, max-age=0, must-revalidate
```
</interfaces>
<tasks>
<task type="auto">
<name>task 1: the static-asset cache policy (web/public/_headers)</name>
<files>web/public/_headers</files>
<read_first>
- bot/wrangler.jsonc (the `assets` binding — `directory: "../web/dist"`; Workers static assets consume `_headers` from the served directory)
- web/vite.config.ts (the `publicDir` — `web/public` is copied verbatim into `web/dist`, so `_headers` lands at the dist root)
- .planning/phases/07-hardening-private-launch/07-RESEARCH.md §Pitfall 10 + §Code Examples
</read_first>
<action>
Create `web/public/_headers` (NEW — the whole file is new). It is the
Cloudflare Workers static-assets header policy, consumed verbatim by the
`[assets]` binding. Content EXACTLY:
```text
# Cloudflare Workers static-assets cache policy (07-04, D-06).
# Vite emits content-HASHED files under /assets/* — a hashed URL's bytes can
# never change, so they are immutable for a year. The HTML entry is NOT
# hashed (it points at the current hashed assets) — it must revalidate on
# every visit or a stale entry would point at deleted asset files.
/assets/*
Cache-Control: public, max-age=31556952, immutable
/
Cache-Control: public, max-age=0, must-revalidate
```
CONSTRAINTS:
- The file is NOT JSON and NOT YAML — it is the `_headers` format (path
pattern line, then indented `Header: value` lines).
- The `Cache-Control` values are EXACTLY as written above (`31556952` seconds
= 365 days; `max-age=0, must-revalidate`).
- The glyphs, the logo files, and the `/glyphs/*` PNGs are NOT hashed by Vite
(they are static `public/` copies) — do NOT give them the immutable rule;
leave them on the default policy. Only `/assets/*` gets `immutable`.
- Add NO other header rule. No `Set-Cookie`, no CSP, no `Access-Control-*`
(the same-origin policy is the app's design; adding a CORS rule here would
widen it).
Then VERIFY the file lands in the build output: run
`npm run build -w theme && npm run build -w web` and confirm `web/dist/_headers`
exists. Vite copies `public/` verbatim, so it will — but the verification is
mandatory, because a `_headers` that never reaches `dist` is a policy that
does not exist.
</action>
<acceptance_criteria>
- `web/public/_headers` exists and contains the literals `/assets/*`, `Cache-Control: public, max-age=31556952, immutable`, `/`, `Cache-Control: public, max-age=0, must-revalidate`
- `grep -c "Set-Cookie\|Access-Control-Allow-Origin" web/public/_headers` returns 0
- `ls web/dist/_headers` succeeds after a build — the policy reaches the served directory
</acceptance_criteria>
<verify>
<automated>export PATH="$HOME/.nvm/versions/node/v24.18.0/bin:$PATH"; cd /home/C-Fu/araharah && npm run build -w theme >/dev/null 2>&1 && npm run build -w web >/dev/null 2>&1 && test -f web/dist/_headers && cat web/dist/_headers && grep -c "immutable" web/dist/_headers</automated>
</verify>
<done>
The `_headers` policy exists in `web/public/`, reaches `web/dist/`, and
carries exactly the two rules (hashed assets immutable, HTML revalidating).
</done>
</task>
<task type="auto">
<name>task 2: the budget-gates library — CPU ladder, bundle bytes, KV write classes, cache headers</name>
<files>scripts/lib/budget-gates.mjs</files>
<read_first>
- routing/scripts/measure-route-cpu.mjs (the whole file — the ladder, the p95 gate, the mitigation ladder message)
- web/test/build-artifact.test.mjs (the BA9 gzip measurement + the input set)
- bot/src/webhook.ts (the `runGuidanceCronTick` body — the zero-write law)
- bot/src/kv/sessions.ts, bot/src/kv/location-pin.ts, bot/src/kv/guidance-sessions.ts (the write classes and TTL constants)
</read_first>
<action>
Create `scripts/lib/budget-gates.mjs` — the measurement library. Its header
comment must state it is a MAINTAINER-TIME REPO TOOL, not a runtime dependency
(copy the law from `routing/scripts/measure-route-cpu.mjs` lines 3–9).
It MUST export:
```javascript
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 runCacheHeaderGate(); // id: "cache-headers" — live headers (or NOT RUN)
```
**`runCpuLadderGate()`** — DO NOT re-implement the A* measurement. Spawn the
existing script as a child process:
`node routing/scripts/measure-route-cpu.mjs` (cwd = repo root), capture its
stdout. Parse the `p95: <n> ms` line (the script prints
`p95: ${p95.toFixed(3)} ms (max … )`). The gate:
- FAIL when the child exits non-zero (the ladder's own gate failed) — the
evidence must carry the child's own `GATE FAIL` line verbatim (the
mitigation ladder text is part of the evidence).
- PASS when p95 ≤ `P95_BUDGET_MS = 8` (the same constant the script uses —
20% under the 10 ms free ceiling).
- When the graph binary `routing/data/kl-roadgraph.v1.bin` is ABSENT, return
`{ pass: false, notRun: true, evidence: "kl-roadgraph.v1.bin is absent — build it (node routing/scripts/build-road-graph.mjs) and re-run" }`
(a missing graph is NOT a pass).
- The evidence string carries the measured p95, max, mean and n, PLUS the
child's LADDER HISTORY context is NOT re-printed — the record cites it.
- ALSO record the free-plan comparison: `10 ms/invocation` is the ceiling;
the gate prints `p95 X ms <= 8 ms budget (10 ms free-plan ceiling)`.
**`runBundleBudgetGate()`** — the BA9 measurement, re-implemented in the
library (the BA9 inputs, the gzip sum, the 204800 byte ceiling). Print the
measurement (`route bundle budget: <n> / 204800 gz bytes`) — the budget is
VISIBLE on every run (D-11's law). FAIL above the ceiling, with the message
carrying the overage.
- Additionally measure the TOTAL gzipped size of `web/dist/assets/*.js` and
print it against a stated ceiling of 500000 bytes (500 KB gz) for the whole
app bundle — the app bundle's real number goes in the record; if it exceeds
the ceiling, the gate FAILS with the overage.
- Also measure the Worker bundle's uncompressed size if `bot/dist` exists;
when it does not, report the number as NOT MEASURED (not as zero).
**`runKvWriteClassGate()`** — the write-class inventory. Scan `bot/src` for
every `.put(` call site and classify each by the key family it writes
(`session:` / `chat:` / `loc:` / `guidance:`), recording the file, the line and
the TTL constant referenced. Assert every write references a NAMED constant
(the PG1 law, re-checked here from the budget side).
- THE CRON LAW: read `bot/src/webhook.ts`, extract the `runGuidanceCronTick`
function body (from `async function runGuidanceCronTick(` to the matching
closing brace — a simple brace counter is fine), and assert that body
contains NO `.put(` and NO `.delete(`. The evidence states the cron write
count (0) explicitly. The cron READS (`.get`, `.list`) and SENDS to
Telegram — those are not KV writes.
- Also print the per-class TTL table (family → constant → seconds) by READING
the constants out of the three `bot/src/kv/*.ts` files with a regex on
`export const <NAME> = <n>;` — never hard-code the numbers.
**`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
`{ pass: false, notRun: true, evidence: "live cache-header probe not run — no deployed origin or credentials" }`.
Print the observed `cache-control` for the HTML entry and for a hashed
`/assets/*` file, and note that `CF-Cache-Status` is probabilistic per
Cloudflare's own footnote (the evidence quotes it, it does not interpret it).
</action>
<acceptance_criteria>
- `scripts/lib/budget-gates.mjs` exists and exports `runCpuLadderGate`, `runBundleBudgetGate`, `runKvWriteClassGate`, `runCacheHeaderGate`
- `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)
</acceptance_criteria>
<verify>
<automated>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));}})"</automated>
</verify>
<done>
The four 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.
</done>
</task>
<task type="auto">
<name>task 3: the budget-audit CLI + the root npm script + the docs/BUDGETS.md record</name>
<files>scripts/budget-audit.mjs, package.json, docs/BUDGETS.md</files>
<read_first>
- theme/scripts/audit.mjs (the exit-code contract and the report shape)
- docs/CONFIGURATION.md (the docs-dir clinical prose style — the leading "surfaces, by kind" table)
- .planning/phases/07-hardening-private-launch/07-CONTEXT.md (D-06 verbatim)
- .planning/phases/07-hardening-private-launch/07-RESEARCH.md §Pattern 5 (the ceiling table)
</read_first>
<action>
Create `scripts/budget-audit.mjs` — the orchestrator (the same shape as
`scripts/privacy-audit.mjs` from plan 07-03, but for budgets).
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()`.
- Prints ONE line per gate: `<id>: PASS|FAIL|NOT RUN — <evidence>`.
- 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`.
- Prints a summary line: `BUDGET AUDIT CLEAN — <n> gate(s) within budget` /
`BUDGET AUDIT FAILED — <n> gate(s) over budget` /
`BUDGET AUDIT NOT RUN — <reason>`.
- EXIT CODES: `0` all required gates passed; `1` at least one gate FAILED;
`2` environment drift (a required measurement could not run — a missing
graph binary, a missing build). A NOT RUN is never a pass.
- The output carries NO secret and NO coordinate.
Add to the ROOT `package.json` `scripts`:
```json
"budget-audit": "node scripts/budget-audit.mjs",
```
Create `docs/BUDGETS.md` — the checked-in budget record (D-06's "one
document"). Follow the `docs/CONFIGURATION.md` clinical prose style. It MUST
contain:
1. A title `# Budgets` and a dated front-matter block naming the measurement
date, the commit SHA (`git rev-parse --short HEAD`), and the measurement
host.
2. A `## Free-plan ceilings` table — EVERY row from 07-RESEARCH.md §Pattern 5,
with the SOURCE cited (`Cloudflare Workers limits / KV limits / pricing`).
3. A `## Current measurements` table — one row per measurement: `| Measurement | Current | Budget | Headroom | Evidence |`
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.
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
(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
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
(a) tighten `DRIVABLE_CLASSES` + rebuild — rejected on correctness (12/25
pairs unreachable), (b) bidirectional A* — kept (sound, no worse),
(c) Workers Paid — rejected ($0 law), and the committed upgrade lever is
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
headers (or an honest "not measured in this run").
7. 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).
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
number from another phase's summary.
</action>
<acceptance_criteria>
- `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`
- `npm run budget-audit` exits 0 on the current tree and prints `BUDGET AUDIT CLEAN`
</acceptance_criteria>
<verify>
<automated>export PATH="$HOME/.nvm/versions/node/v24.18.0/bin:$PATH"; cd /home/C-Fu/araharah && npm run budget-audit 2>&1 | tail -35 && npm run budget-audit >/dev/null 2>&1; echo "exit=$?"</automated>
</verify>
<done>
`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.
</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| The free-plan ceiling → the measurement | The ceilings are documented facts; the current numbers are measurements. A stale ceiling silently mis-states headroom. |
| The cron tick → the KV namespace | The cron is the one code path that runs without a user request; a stray write there burns the 1,000/day KV write budget. |
| Static assets → the browser cache | A wrong cache policy serves a stale HTML entry that points at deleted hashed assets — a broken app for every driver. |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-07-18 | Denial of Service | the cron tick — a KV write in the scheduled path burns the free-plan write budget (1,000/day) | mitigate | `runKvWriteClassGate` extracts the `runGuidanceCronTick` body and asserts it contains no `.put(` and no `.delete(` — the zero-write law is source-proven, not claimed; the evidence prints the count (0). |
| T-07-19 | Denial of Service | the Worker CPU budget — an A* p95 above 10 ms makes the Worker return Error 1102 | mitigate | The ladder is RE-RUN (not re-implemented) and its p95 is compared to the 8 ms budget; a non-zero child exit FAILS the gate with the child's own mitigation-ladder text. The record additionally states the c3 honest-degradation class for cross-metro routes. |
| 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. |
</threat_model>
<verification>
Run after all tasks:
```bash
export PATH="$HOME/.nvm/versions/node/v24.18.0/bin:$PATH"
cd /home/C-Fu/araharah
npm run build -w theme && npm run build -w web
npm run budget-audit
npm run budget-audit -- --live
```
The first must exit 0 with `BUDGET AUDIT CLEAN`. The second must exit 0 (with
the live header evidence) or exit 2 with `BUDGET AUDIT NOT RUN` — it must
NEVER exit 0 while reporting a measurement as skipped.
</verification>
<success_criteria>
- `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.
- 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.
</success_criteria>
<output>
After completion, create `.planning/phases/07-hardening-private-launch/07-04-SUMMARY.md`
</output>
@@ -0,0 +1,478 @@
---
phase: 07-hardening-private-launch
plan: 05
type: execute
wave: 1
depends_on: []
files_modified:
- bot/src/security/initdata.ts
- bot/test/initdata.test.mjs
- bot/wrangler.jsonc
- bot/src/i18n/strings.ts
- docs/KNOWN-LIMITATIONS.md
- .planning/phases/03-mini-app-shell-geolocation/03-DEVICE-MATRIX.md
autonomous: true
requirements:
- PX-04
must_have_notes:
- "This plan pays the deferred debts (D-07) and ships the known-limitations document (D-08) — SC4's second half, delivered under the PX-04 phase."
must_haves:
truths:
- "The exact-900-second initData freshness boundary is pinned by a test — a `>=` regression cannot ship."
- "The dead `ParsedInitData` export and the unreachable branch are gone; the read helpers carry their intent comment."
- "The stale wrangler.jsonc comment is replaced with a present-tense record of the live namespace id."
- "A driver can reach the known-limitations document from /help in BOTH locales with one pointer line."
- "The limitations document tells the truth about OSM data gaps, the cross-metro CPU degradation, the /cari menu defect, the beta quality, and the roadmap pointers."
artifacts:
- path: "bot/test/initdata.test.mjs"
provides: "The T4b exact-900s boundary vector (IN-03)"
contains: "age 900 s"
- path: "bot/src/security/initdata.ts"
provides: "The IN-01 cleanup — the dead export deleted, the intent comments added"
contains: "tampered-vs-malformed"
- path: "bot/wrangler.jsonc"
provides: "The IN-02 present-tense namespace comment"
contains: "chat_sessions namespace created via"
- path: "bot/src/i18n/strings.ts"
provides: "The /help limitations pointer line (en + ms)"
contains: "Known limits:"
- path: "docs/KNOWN-LIMITATIONS.md"
provides: "The known-limitations document for drivers AND forks (D-08)"
contains: "Known limitations"
key_links:
- from: "bot/src/i18n/strings.ts"
to: "docs/KNOWN-LIMITATIONS.md"
via: "the helpMenu pointer line names the doc's public URL — a SINGLE constant, never a scattered URL"
pattern: "Known limits:"
- from: "bot/src/i18n/strings.ts"
to: "bot/scripts/register-commands.mjs"
via: "the pointer line does NOT start with '/', so the menu parse (CS5) and the setMyCommands derivation are unaffected"
pattern: "Had panduan:"
- from: "bot/src/security/initdata.ts"
to: "bot/test/initdata.test.mjs"
via: "the exact-900s vector pins the strictly-greater comparator's INCLUSIVE edge"
pattern: "900"
---
<objective>
Pay every debt D-07 parked, and ship the known-limitations document the drivers
(and forks) need — with a single pointer line from /help.
Purpose: D-07 — the three Phase-2 review INFOs (IN-01 dead code, IN-02 stale
comment, IN-03 the unpinned 900 s security boundary), the /cari command-menu
re-arm, and the Phase-3 A1/A3 assumption notes. D-08 — the roadmap's SC4
requires a known-limitations document BEFORE the first real drivers; an honest
one is also the product's integrity claim. The privacy audit in plan 07-03
links to it, and the launch gate in plan 07-06 checks it.
Output: the IN-01/IN-02/IN-03 fixes, the /cari re-arm record, the A1/A3
closure, `docs/KNOWN-LIMITATIONS.md` (new), and the /help pointer line in
`bot/src/i18n/strings.ts` (en + ms).
</objective>
<execution_context>
@$HOME/.config/opencode/get-shit-done/workflows/execute-plan.md
@$HOME/.config/opencode/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/phases/07-hardening-private-launch/07-CONTEXT.md
@.planning/phases/07-hardening-private-launch/07-RESEARCH.md
@.planning/phases/07-hardening-private-launch/07-PATTERNS.md
@.planning/phases/02-bot-skeleton-initdata-security/02-REVIEW.md
<interfaces>
The IN-03 boundary vector — copy the T4 harness shape verbatim from
`bot/test/initdata.test.mjs` lines 172–189:
```javascript
test("T4 freshness boundary: age 899 s → ok:true", async () => {
const validateInitData = await loadValidator();
const now = Math.floor(Date.now() / 1000);
const raw = signedUser(now - 899);
const res = await validateInitData(raw, TEST_TOKEN);
assert.equal(res.ok, true, `expected ok:true at 899 s, got ${JSON.stringify(res)}`);
});
```
The comparator under test — `bot/src/security/initdata.ts` lines 193–196:
```typescript
// — stale: age strictly beyond the D-13 window (899 ok / 900 ok / 901 stale) —
if (nowSeconds - authDate > maxAgeSeconds) {
return { ok: false, reason: "stale" as const };
}
```
The IN-01 targets — `bot/src/security/initdata.ts` lines 59–66 (the dead
export) and lines 87–104 / 154–165 / 198–214 (the parse blocks whose catch
branches keep raw/undefined):
```typescript
/** Shape of a fully parsed initData payload (hash/signature excluded from
* rawFields — they are auth metadata, not payload fields). */
export interface ParsedInitData {
user: WebAppInitUser;
chat?: WebAppInitChat;
authDate: number;
rawFields: Map<string, string>;
}
```
The IN-02 target — `bot/wrangler.jsonc` line 2 (the stale comment) versus the
live id on the `kv_namespaces` line:
```jsonc
// KV namespace id is filled in plan 03 task 1 after `npx wrangler kv namespace create chat_sessions`
...
"kv_namespaces": [{ "binding": "chat_sessions", "id": "59f58a54bc44483e89872b20a005a5eb" }]
```
The CS5 gate — `bot/test/commands.test.mjs` lines 134–155 (the menu parse and
the exact-command assertion). The limitations pointer line MUST NOT start with
`/` or it is parsed as a command and breaks CS5:
```javascript
function menuEntries(helpMenu) {
return helpMenu
.split("\n")
.filter((line) => line.startsWith("/"))
.map((line) => {
const em = line.indexOf(" — ");
assert.ok(em !== -1, `helpMenu line lacks its " — " description marker: ${line}`);
return { command: line.slice(1, em), description: line.slice(em + 3).trim() };
});
}
```
The `helpMenu` strings as they stand — `bot/src/i18n/strings.ts` lines 94–95
(en) and 132–133 (ms):
```typescript
helpMenu:
"Commands:\n/help — How araharah works and what it keeps\n/privacy — What araharah stores and never stores\n/cari — Search for a destination in the KL metro",
```
```typescript
helpMenu:
"Arahan:\n/help — Cara araharah berfungsi dan apa yang ia simpan\n/privacy — Apa yang araharah simpan dan tidak pernah simpan\n/cari — Cari destinasi di kawasan KL",
```
The `/help` handler — `bot/src/bot/commands/help.ts` lines 18–20 (the reply is
the `helpMenu` string verbatim, so the pointer line rides it automatically):
```typescript
export function helpReplyText(locale: Locale): string {
return strings[locale].helpMenu;
}
```
The re-arm tool — `bot/scripts/register-commands.mjs` (it arms every scope it
may shadow: default, default+ms, all_private_chats, all_private_chats+ms, and
clears the group scopes; it derives its descriptions from `helpMenu`).
</interfaces>
<tasks>
<task type="auto">
<name>task 1: pay IN-01 and IN-02 (dead code + the stale wrangler comment) and pin IN-03 (the exact-900s boundary)</name>
<files>bot/src/security/initdata.ts, bot/test/initdata.test.mjs, bot/wrangler.jsonc</files>
<read_first>
- bot/src/security/initdata.ts (the dead export, the parse blocks, the comparator)
- bot/test/initdata.test.mjs (the T4 harness shape + `signedUser`/`loadValidator` helpers)
- bot/wrangler.jsonc (line 2's stale comment versus the live namespace id)
- .planning/phases/02-bot-skeleton-initdata-security/02-REVIEW.md §Info (IN-01, IN-02, IN-03 verbatim)
</read_first>
<action>
**IN-01** — in `bot/src/security/initdata.ts`:
1. DELETE the dead `ParsedInitData` interface (lines 59–66) entirely, INCLUDING
its doc comment. It is never constructed and never returned (`validateInitData`
returns `InitDataResult`). Before deleting, run
`grep -rn "ParsedInitData" bot/ web/ routing/ search/` and confirm the ONLY
hits are the declaration itself — if another file imports it, do NOT delete
it; instead re-point it with an intent comment naming why it stays, and
record that in the SUMMARY.
2. Add ONE intent comment over the `parseFields` catch block (lines 96–100)
reading, in the file's voice:
`// Intent: an undecodable percent-escape keeps the RAW value so the HMAC compare fails ("tampered") rather than silently dropping the field — malformed-vs-tampered precedence (D-14).`
3. Add ONE intent comment over the `user` JSON parse catch (lines 161–164)
reading:
`// Intent: a malformed user payload is the "malformed" class, not a tampered one — the field never reaches the HMAC path.`
4. Add ONE intent comment over the `chat` JSON parse catch (lines 210–213)
reading:
`// Intent: a malformed chat payload degrades to "no chat context" (a private chat) — it never fails an otherwise-valid private-chat payload.`
**IN-02** — in `bot/wrangler.jsonc`, REPLACE line 2 (the stale
"filled in plan 03 task 1" comment) with this present-tense record, in the
file's own comment voice:
```jsonc
// chat_sessions namespace created via `npx wrangler kv namespace create chat_sessions` — the id below is live.
```
**IN-03** — in `bot/test/initdata.test.mjs`, add EXACTLY ONE new case
immediately after the existing `T4 freshness boundary: age 901 s → reason stale`
case, copying that case's harness shape verbatim (`loadValidator()`,
`signedUser(now - 900)`, `TEST_TOKEN`):
```javascript
test("T4 freshness boundary: age 900 s → ok:true (the boundary is INCLUSIVE)", async () => {
const validateInitData = await loadValidator();
const now = Math.floor(Date.now() / 1000);
const raw = signedUser(now - 900);
const res = await validateInitData(raw, TEST_TOKEN);
assert.equal(res.ok, true, `expected ok:true at exactly 900 s, got ${JSON.stringify(res)}`);
});
```
The case name MUST contain the literal `age 900 s` — it pins the comparator
direction (`>` vs `>=`) that the implementation comment already claims. Do
NOT change the comparator in `initdata.ts` — the implementation is correct
today; the test is what makes the claim a pin.
Finally, run `npm test -w bot` and `npm run bot:typecheck` — both must pass.
</action>
<acceptance_criteria>
- `grep -c "ParsedInitData" bot/src/security/initdata.ts` returns 0 (the dead export is gone) and `grep -rn "ParsedInitData" bot/src web/src` returns nothing
- `grep -c "Intent:" bot/src/security/initdata.ts` returns ≥ 3 (the three read-helper intent comments)
- `grep -c "age 900 s" bot/test/initdata.test.mjs` returns ≥ 1 (the IN-03 vector)
- `grep -c "plan 03 task 1" bot/wrangler.jsonc` returns 0 (the stale comment is gone) and `grep -c "the id below is live" bot/wrangler.jsonc` returns 1
- `npm test -w bot` exits 0 with `0 failed`
</acceptance_criteria>
<verify>
<automated>export PATH="$HOME/.nvm/versions/node/v24.18.0/bin:$PATH"; cd /home/C-Fu/araharah && npm test -w bot 2>&1 | tail -12 && npm run bot:typecheck 2>&1 | tail -5</automated>
</verify>
<done>
IN-01's dead export is gone and the three read helpers carry their intent
comments; IN-02's comment states the post-fact truth; IN-03's exact-900s
vector is green.
</done>
</task>
<task type="auto">
<name>task 2: the known-limitations document (D-08) + the /help pointer line (en + ms)</name>
<files>docs/KNOWN-LIMITATIONS.md, bot/src/i18n/strings.ts</files>
<read_first>
- docs/CONFIGURATION.md (the clinical-prose style: a leading "surfaces, by kind" table, `File | Applies to` scoping, backticked identifiers, bold for requirements, every statement names its evidence)
- bot/src/i18n/strings.ts (the D-05 law in the header + the two helpMenu strings)
- bot/test/commands.test.mjs (the CS5 menu parse — the pointer line must not start with "/")
- .planning/phases/07-hardening-private-launch/07-CONTEXT.md (D-08 verbatim)
- routing/scripts/measure-route-cpu.mjs (the LADDER HISTORY, lines 30–40 — the c3 evidence)
</read_first>
<action>
Create `docs/KNOWN-LIMITATIONS.md` (NEW — the whole file is new), in the
`docs/CONFIGURATION.md` clinical-prose style (no marketing, no emoji, one
honest sentence per claim, every claim naming its evidence). It is written for
TWO readers — a DRIVER and a FORK — and must contain these sections in this
order:
1. `# Known limitations` + a one-paragraph lead: what araharah is today (a
KL-metro turn-by-turn navigator inside Telegram, on OpenStreetMap data),
and the plain statement that this page lists what it does NOT do well.
2. `## Data coverage` — the OSM gaps, honestly:
- **No traffic awareness.** There is no live traffic feed; the ETA is a
distance-and-speed estimate from the route model, not a prediction.
- **No lane guidance.** There is no lane-level arrow guidance; the
maneuver glyph names the turn, not the lane.
- **Speed limits appear only where OSM maps them** — many KL roads have
no `maxspeed` tag, so no limit is shown.
- **Turn restrictions and one-ways** are only as good as the OSM snapshot
the road graph was built from; a recent change may not be reflected.
- **The road graph is a build-time artifact** (`routing/data/kl-roadgraph.v1.bin`),
rebuilt by a maintainer — forks rebuild their own (`docs/ROAD-GRAPH.md`).
3. `## Cross-metro routes may not resolve` — the c3 class, in one paragraph:
short product routes (2–8 km) measure 2–8 ms of Worker CPU (inside the
free plan's 10 ms), while cross-metro routes (20–30 km) measured p95
38–93 ms and therefore degrade HONESTLY to the router-pending card
("the router is not ready for this trip") rather than freezing or drawing
a wrong line. Evidence: `routing/scripts/measure-route-cpu.mjs` (LADDER
HISTORY) and the Phase 6 plan summary. The committed upgrade lever is the
ALT landmark preprocessing — a NEXT-MILESTIME item, NOT available in this
release. State the free-tier reason: Workers Paid was rejected (the $0
mandate).
4. `## The /cari command may be missing from the Telegram menu` — the
Telegram `setMyCommands` non-default-scope defect. State the symptom: the
menu shows /help and /privacy but not /cari (Telegram resolves a chat's
menu from the narrowest scope that has ANY commands set, and a stale
all_private_chats set shadows the default scope). The WORKAROUND: type
`/cari` in the chat — the command works. The bot re-arms every scope
(`bot/scripts/register-commands.mjs`) but the Telegram client may cache the
old menu until the app restarts.
5. `## Beta quality` — one honest paragraph: this is a private beta with a
handful of drivers in Kuala Lumpur; the device matrix was walked on real
Android, iOS, Telegram Desktop and a plain browser; battery drain was
MEASURED on one Android phone (record the method and the caveats pointer:
the launch gate file holds the run); fullscreen and orientation behaviour
varies by Telegram client version, and the app degrades to a usable
portrait/landscape layout when the platform refuses a fullscreen request.
Name the KNOWN limits honestly: no voice guidance, no live trip sharing,
no toll toggle, no traffic ETA — those are post-validation milestones, not
defects.
6. `## What is being worked on next` — the roadmap pointers D-08 names, each
with its honest status: the WASM-router track (a client-side router as the
static-hosted endgame), the ALT landmark preprocessing (the cross-metro
lever), and the "engine B" (a fully client-side webview router). State
that none of them are in this release.
7. `## Privacy in one paragraph` — coordinates live only in memory and in
TTL-expiring KV (15-minute sessions, 5-minute location pins, 2-hour
guidance envelopes); there is NO telemetry of any kind in this release —
no analytics, no crash reporting, no usage tracking; the shipped bundle
contacts only the map tile host, the Telegram API, and the app's own
Worker. Point at `docs/PRIVACY-AUDIT.md` for the full record.
8. `## How to run the audit yourself` — for the FORK reader: the exact
command `npm run privacy-audit` and the exact command `npm run budget-audit`,
with one line on what each proves and what exit code 2 means (environment
drift, never a pass).
THEN — in `bot/src/i18n/strings.ts`, add ONE pointer line to the END of BOTH
`helpMenu` strings. It MUST NOT start with `/` (CS5). Use EXACTLY these
strings:
- en — append `\nKnown limits: https://github.com/araharah/araharah/blob/main/docs/KNOWN-LIMITATIONS.md`
- ms — append `\nHad panduan: https://github.com/araharah/araharah/blob/main/docs/KNOWN-LIMITATIONS.md`
The URL MUST be identical in both locales and MUST appear in EXACTLY ONE place
per locale (the strings module is the single copy source — the D-05 law). If
the repository's public URL is not `github.com/araharah/araharah`, use the
repo's actual remote URL (run `git remote -v` and use the HTTPS remote) —
but the SAME URL in both locales, and record the choice in the SUMMARY.
After the edit, run `npm test -w bot` — CS5 must still pass (the pointer line
is filtered out of the menu parse because it does not start with `/`).
</action>
<acceptance_criteria>
- `docs/KNOWN-LIMITATIONS.md` exists and contains the section headings `## Data coverage`, `## Cross-metro routes may not resolve`, `## The /cari command may be missing from the Telegram menu`, `## Beta quality`, `## What is being worked on next`, `## Privacy in one paragraph`, `## How to run the audit yourself`
- `grep -c "KNOWN-LIMITATIONS.md" bot/src/i18n/strings.ts` returns exactly 2 (one per locale — the single-copy law)
- `grep -c "Known limits:" bot/src/i18n/strings.ts` returns 1 and `grep -c "Had panduan:" bot/src/i18n/strings.ts` returns 1
- `grep -c "helpMenu" bot/src/i18n/strings.ts` returns ≥ 2 (unchanged shape)
- `npm test -w bot` exits 0 with `0 failed` (CS5 still green — the pointer line is not parsed as a command)
</acceptance_criteria>
<verify>
<automated>export PATH="$HOME/.nvm/versions/node/v24.18.0/bin:$PATH"; cd /home/C-Fu/araharah && npm test -w bot 2>&1 | tail -12 && grep -c "KNOWN-LIMITATIONS.md" bot/src/i18n/strings.ts</automated>
</verify>
<done>
The limitations document exists with all seven sections and the honest c3
and /cari content, and /help carries one pointer line per locale that the
CS5 menu gate still ignores.
</done>
</task>
<task type="auto">
<name>task 3: the /cari re-arm attempt + the A1/A3 assumption closure, recorded honestly</name>
<files>.planning/phases/03-mini-app-shell-geolocation/03-DEVICE-MATRIX.md</files>
<read_first>
- .planning/phases/03-mini-app-shell-geolocation/03-DEVICE-MATRIX.md (the A1 stale-fix leg at line 44 and the A3 local-dev note at line 99, plus the FINAL VERDICT table at line 258)
- bot/scripts/register-commands.mjs (the re-arm tool — its scope table)
- .planning/phases/07-hardening-private-launch/07-CONTEXT.md (D-07 items 2 and 3)
</read_first>
<action>
Two records, both in
`.planning/phases/03-mini-app-shell-geolocation/03-DEVICE-MATRIX.md`
(append a new dated section at the END of that file — do not rewrite its
history):
**A — the /cari re-arm attempt.** Attempt to re-arm the command menu by
running the existing tool:
```bash
npm run bot:commands
```
This requires `BOT_TOKEN` in `bot/.dev.vars` (gitignored) or the environment.
Record the outcome HONESTLY in a new `## 2026-10-25 — Phase 7 debt closure (/cari re-arm + A1/A3)` section:
- If the script ran and every scope reported `ok:true`, record the six scope
labels it printed and the literal line `all command scopes armed`.
- If it could not run (no token, no network, a Telegram API error), record the
EXACT error line and mark the re-arm **NOT RUN** — a fabricated "re-armed"
claim is forbidden (D-05's honesty law).
- In BOTH cases, state the residual risk: Telegram clients cache the command
menu; the defect is a Telegram `setMyCommands` scope-resolution behaviour,
not a bot bug — and `docs/KNOWN-LIMITATIONS.md` now carries the user-facing
statement plus the workaround (typing `/cari`).
**B — the A1/A3 assumption closure.** In the same section, close or re-point
the two Phase-3 assumptions:
- **A1** (the Android stale-fix hypothesis — that
`navigator.geolocation.getCurrentPosition()` refreshes the stale native
cache so the next `LocationManager.getLocation()` returns fresh coordinates).
The Phase-3 device matrix recorded the A1 leg as passed with the note
"A1 notes stand in 03-RESEARCH.md" — the walk completed WITHOUT needing the
toggle. Record the honest closure: **A1 is NOT RETIRED as a mechanism
hypothesis** — it was simply not needed on the tested device and route;
the app's chain degrades gracefully to polling when the stale-fix path does
not fire. The risk it covers is therefore MITIGATED-BY-DESIGN rather than
closed. State that in one sentence and point at the degradation path
(`web/src/location/chain.ts`).
- **A3** (that `localhost:8787` is a secure context so `navigator.geolocation`
works under `wrangler dev` while a LAN-IP dev URL would not). Record the
closure: **A3 IS RETIRED as a decision constraint** — the project deploys to
a remote origin (`workers.dev`), not to a LAN-IP dev server, so the
assumption no longer constrains any open decision. State it in one sentence.
Do NOT edit the Phase-3 FINAL VERDICT table (it is the historical record); the
new dated section is the closure.
</action>
<acceptance_criteria>
- `.planning/phases/03-mini-app-shell-geolocation/03-DEVICE-MATRIX.md` contains the literal `## 2026-10-25 — Phase 7 debt closure (/cari re-arm + A1/A3)`
- the new section contains the literal `A1` and the literal `A3` and the literal `/cari`
- the new section contains either `all command scopes armed` (the run happened) OR an explicit `NOT RUN` with the exact error line (it did not) — never a fabricated success
- `grep -c "FINAL VERDICT" .planning/phases/03-mini-app-shell-geolocation/03-DEVICE-MATRIX.md` returns 1 (the historical table is untouched)
</acceptance_criteria>
<verify>
<automated>export PATH="$HOME/.nvm/versions/node/v24.18.0/bin:$PATH"; cd /home/C-Fu/araharah && grep -c "Phase 7 debt closure" .planning/phases/03-mini-app-shell-geolocation/03-DEVICE-MATRIX.md && grep -c "A3 IS RETIRED" .planning/phases/03-mini-app-shell-geolocation/03-DEVICE-MATRIX.md</automated>
</verify>
<done>
The /cari re-arm outcome and the A1/A3 closure are recorded in the
Phase-3 device matrix, honestly — with the re-arm either armed or
explicitly NOT RUN.
</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| The Telegram client → the bot's command menu | The client resolves the menu from the narrowest scope with any commands; a stale scope silently hides a command. |
| The initData comparator → the session gate | The 900 s freshness edge is the authentication boundary; a `>=` regression widens the acceptance window by one second. |
| The limitations document → the driver | A doc that overstates capability is a false promise; the doc must state only what the measurements support. |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-07-08 | Spoofing (Authentication Bypass) | `bot/src/security/initdata.ts` — the 900 s freshness comparator | mitigate | The exact-900s inclusive boundary is pinned by a named test vector (`T4 freshness boundary: age 900 s → ok:true`); a `>=` regression turns that case red. The comparator itself is unchanged — the test is what makes the existing claim a pin. |
| T-07-23 | Spoofing | `bot/wrangler.jsonc` — the stale comment telling a future maintainer to re-create the namespace | mitigate | IN-02 replaces the plan-scoped comment with a present-tense record naming the live id's provenance; a maintainer following the comment can no longer create a replacement namespace. |
| T-07-24 | Elevation of Privilege | the `/cari` menu gap — a driver cannot find the search command | mitigate | The re-arm tool arms every shadowing scope (default, default+ms, all_private_chats, all_private_chats+ms) and clears the group scopes; the residual Telegram-side caching behaviour is disclosed to drivers with the type-the-command workaround in `docs/KNOWN-LIMITATIONS.md`. The command itself was always functional — the gap is discoverability, not authorization. |
| T-07-25 | Information Disclosure | `docs/KNOWN-LIMITATIONS.md` — a doc that names internal identifiers | accept | The doc names the road-graph binary path and the CPU measurement script — both are PUBLIC repository artifacts under GPL-3.0 and are documented for forks by design. No secret, no token, no coordinate appears. |
| T-07-26 | Repudiation | the debt closure record | mitigate | The re-arm outcome is recorded either as the printed scope results or as an explicit `NOT RUN` with the verbatim error — the record never asserts an outcome that did not happen (the phase's no-fake protocol). |
</threat_model>
<verification>
Run after all tasks:
```bash
export PATH="$HOME/.nvm/versions/node/v24.18.0/bin:$PATH"
cd /home/C-Fu/araharah
npm test -w bot
npm run bot:typecheck
```
Both must exit 0. `bot/test/commands.test.mjs`'s CS5 case must pass — the
limitations pointer line must stay invisible to the command-menu parse.
</verification>
<success_criteria>
- The dead `ParsedInitData` export is gone, the three read helpers carry their intent comments, and the stale wrangler comment states the post-fact truth.
- The exact-900s inclusive boundary is pinned by a green test vector.
- `docs/KNOWN-LIMITATIONS.md` exists with all seven sections, covering OSM data gaps, the c3 cross-metro degradation with its measurement evidence, the /cari menu defect with its workaround, the beta quality statement, the roadmap pointers, the privacy posture, and the fork-facing audit commands.
- `/help` carries exactly one limitations pointer line per locale, in the strings module only, and CS5 still passes.
- The /cari re-arm attempt and the A1/A3 closure are recorded in the Phase-3 device matrix honestly.
</success_criteria>
<output>
After completion, create `.planning/phases/07-hardening-private-launch/07-05-SUMMARY.md`
</output>
@@ -0,0 +1,503 @@
---
phase: 07-hardening-private-launch
plan: 06
type: execute
wave: 3
depends_on:
- "07-01"
- "07-02"
- "07-03"
- "07-04"
- "07-05"
files_modified:
- .planning/phases/07-hardening-private-launch/07-LAUNCH-GATE.md
- .planning/STATE.md
autonomous: false
requirements:
- PX-04
must_haves:
truths:
- "One consolidated walk covers Android, iOS, Telegram Desktop and a plain browser across seven interactions, and every leg carries a recorded PASS/FAIL/NOT RUN verdict — never a synthesized one."
- "The battery result is a RECORDED real measurement from one Android guidance run of at least 30 screen-on minutes — never extrapolated, never synthesized."
- "The launch checklist is signed only when every automated gate is green, the known-limitations doc is shipped, the privacy audit is signed, and the budget record is green."
- "The launch act itself is the user's — the workflow produces the evidence and the checklist, never the distribution."
artifacts:
- path: ".planning/phases/07-hardening-private-launch/07-LAUNCH-GATE.md"
provides: "The signed launch gate — the device matrix, the battery record, and the D-09 checklist"
contains: "FINAL VERDICT"
- path: ".planning/STATE.md"
provides: "The milestone completion record (D-09 — the milestone completes when the checklist is signed)"
contains: "milestone"
key_links:
- from: ".planning/phases/07-hardening-private-launch/07-LAUNCH-GATE.md"
to: ".planning/phases/07-hardening-private-launch/07-03-PLAN.md"
via: "the checklist's privacy-audit line references the signed record in docs/PRIVACY-AUDIT.md"
pattern: "PRIVACY-AUDIT"
- from: ".planning/phases/07-hardening-private-launch/07-LAUNCH-GATE.md"
to: "docs/KNOWN-LIMITATIONS.md"
via: "the checklist's known-limitations line names the shipped doc"
pattern: "KNOWN-LIMITATIONS"
---
<objective>
Close the phase honestly: one consolidated device-matrix walk, one recorded
Android battery run, the D-09 launch checklist — and the milestone's
completion record.
Purpose: D-05 — the QA pass is ONE walk (four clients × seven interactions),
not four separate sessions. D-04 — the battery target is a RECORDED real
measurement, never synthesized. D-09 — the private launch IS the checklist
being signed; the launch act itself is the user's. This plan produces the
gate FILE (the protocol + the record) and then blocks for the human.
Output: `.planning/phases/07-hardening-private-launch/07-LAUNCH-GATE.md` (new
— the protocol, the empty result tables, and the signature block) and, after
the human signs, the `.planning/STATE.md` milestone completion record.
</objective>
<execution_context>
@$HOME/.config/opencode/get-shit-done/workflows/execute-plan.md
@$HOME/.config/opencode/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/07-hardening-private-launch/07-CONTEXT.md
@.planning/phases/07-hardening-private-launch/07-RESEARCH.md
@.planning/phases/07-hardening-private-launch/07-PATTERNS.md
<interfaces>
The gate-file structure to copy — `.planning/phases/06-turn-by-turn-guidance-live-driving/06-DEVICE-GATE.md`
(its blocking header at lines 3–8, its part tables at lines 32–43, its
executor notes at lines 59–68, and its FINAL VERDICT section at lines 72–82):
```markdown
**Status: BLOCKING — awaiting the human.** Everything automatable is green: …
Per the plan's law: **device results are never faked** — this file records the
protocol; the SUMMARY records the verdict verbatim once the drive runs.
A FAIL holds the phase open.
| # | Step | Verify |
|---|------|--------|
## Executor notes for the run
- **Never fake**: if a step cannot be executed (weather, road, time), mark it NOT RUN —
do not invent an outcome. The phase stays open on any FAIL or NOT RUN.
## FINAL VERDICT
| Part | Verdict |
|------|---------|
```
The battery record fields (from 07-RESEARCH.md §Pitfall 11 and D-04):
| Field | Requirement |
|-------|-------------|
| Device / model | required |
| OS + Telegram client version | required |
| Screen brightness | required |
| Network (Wi-Fi / cellular) | required |
| Screen-on interval | ≥ 30 minutes |
| Battery % at start | required |
| Battery % at end | required |
| Route / drive description | required |
| Elapsed minutes | required |
| Computed %/hr | (end − start) / (elapsed minutes / 60) |
| Caveats | required (background apps, signal loss, other load) |
The seven interactions (D-05) — each leg is tested against every client:
1. boot
2. search → route
3. guidance
4. minimize / resume
5. trip-end
6. theme flip
7. landscape / fullscreen
The D-09 launch checklist (six items):
- known-limitations shipped
- privacy audit signed
- budget record green
- full device matrix walked
- `/help` + `/privacy` accurate
- repo pushed public under GPL-3.0
</interfaces>
<tasks>
<task type="auto">
<name>task 1: write the launch gate file (the protocol, the empty result tables, the checklist, the signature block)</name>
<files>.planning/phases/07-hardening-private-launch/07-LAUNCH-GATE.md</files>
<read_first>
- .planning/phases/06-turn-by-turn-guidance-live-driving/06-DEVICE-GATE.md (the WHOLE file is the structural template)
- .planning/phases/03-mini-app-shell-geolocation/03-DEVICE-MATRIX.md (the six-leg table + the FINAL VERDICT table shape)
- .planning/phases/07-hardening-private-launch/07-CONTEXT.md (D-04, D-05, D-09 verbatim)
- .planning/phases/07-hardening-private-launch/07-RESEARCH.md §Pitfalls 11 and 12
</read_first>
<action>
Create `.planning/phases/07-hardening-private-launch/07-LAUNCH-GATE.md` (NEW —
the whole file is new), copying `06-DEVICE-GATE.md`'s structure exactly. It
MUST contain, in this order:
**1. A blocking status header.** Name what is already green at the time of
writing — the suite counts and the exact commands (`npm run bot:test`,
`npm run web:test`, `npm run routing:test`, `npm run search:test`,
`npm run privacy-audit`, `npm run budget-audit`, `npm run capture -w web`) —
and include the never-fake law VERBATIM in the file's own voice:
"device results are never faked — this file records the protocol; the SUMMARY
records the verdict verbatim once the walk runs. A FAIL holds the phase open."
**2. `## Part A — the consolidated device-matrix walk (D-05)`.** A FOUR-CLIENT
× SEVEN-INTERACTION table. The four clients are: Android (real phone,
Telegram Android), iOS (real phone, Telegram iOS), Telegram Desktop, and a
plain browser. The seven interactions are: boot · search→route · guidance ·
minimize/resume · trip-end · theme flip · landscape/fullscreen. The table
shape is one row per CLIENT with a per-interaction verdict column set:
```markdown
| Client | boot | search→route | guidance | minimize/resume | trip-end | theme flip | landscape/fullscreen | Notes |
| --- | --- | --- | --- | --- | --- | --- | --- | --- |
| Android (real phone, Telegram Android) | ⬜ | ⬜ | ⬜ | ⬜ | ⬜ | ⬜ | ⬜ | |
| iOS (real phone, Telegram iOS) | ⬜ | ⬜ | ⬜ | ⬜ | ⬜ | ⬜ | ⬜ | |
| Telegram Desktop | ⬜ | ⬜ | ⬜ | ⬜ | ⬜ | ⬜ | ⬜ | |
| Plain browser | ⬜ | ⬜ | ⬜ | ⬜ | ⬜ | ⬜ | ⬜ | |
```
The verdict vocabulary is `PASS` / `FAIL` / `NOT RUN` — one word per cell,
plus the free-text Notes column. Define the vocabulary right above the table:
"PASS = the interaction completed as designed on this client · FAIL = it did
not (the phase stays open) · NOT RUN = the client or the step was unavailable
(NOT RUN is never PASS)."
Add a `### What to check per interaction` list — one short line per
interaction, stating the observable pass condition:
- boot: the app reaches LIVE (or the DESIGNED denied state on a client with
no geolocation — the denied state IS the pass condition there, a blank
screen is a FAIL).
- search→route: a destination resolves to a KL landmark, the route draws, the
route card shows time + distance.
- guidance: the maneuver banner shows distance + arrow + street; the ETA
advances without whiplash; the haptic fires at the maneuver (where the
client supports it).
- minimize/resume: minimizing the app mid-drive produces a chat turn-card
within ~3–4 minutes; re-opening resumes at the progressed position.
- trip-end: END TRIP → the confirm card → abandon shows the trip-ended card
and the envelope is deleted.
- theme flip: the chrome re-slots light↔dark mid-session, the strip stays
matte-black, and THE MAP IS UNCHANGED (same style, no flash, no camera jump).
- landscape/fullscreen: rotating the device (or the desktop window) keeps the
banner, the maneuver list, the guidance card and the map all usable with no
overlap and no clipping; on Telegram 8.0+ clients the Mini App enters
fullscreen at guidance start, and on older clients it simply stays
portrait/landscape-usable (that IS the pass condition there).
**3. `## Part B — the Android battery record (D-04)`.** A table with the
field list from the interfaces section above, all values left as `⬜` EXCEPT
the rows that name a REQUIREMENT (device, OS, client version, brightness,
network, screen-on interval ≥ 30 min, start %, end %, route, elapsed minutes,
computed %/hr, caveats). Below the table, the honest rule, in the file's
voice: "The <10%/hr target is RECORDED, never synthesized. If the run is
shorter than 30 screen-on minutes, or it is interrupted, mark the row NOT RUN
and keep this gate open. Do not extrapolate a percentage from a shorter run."
**4. `## Part C — the automated half (re-run at gate time)`.** A table of the
exact commands with a `⬜` result column, so the human can re-run them at
gate time and see the counts:
`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` · `npm run privacy-audit` ·
`npm run budget-audit` · `npm run capture -w web` ·
`npm run privacy-audit -- --live` · `npm run budget-audit -- --live`.
Note under the table: "A `--live` probe that reports NOT RUN (exit code 2)
means the credentials or the environment were absent — that is NOT a pass.
The launch checklist stays unsigned until the live probes are green."
**5. `## Part D — the D-09 launch checklist`.** Six checkbox rows, each with
the evidence that satisfies it:
- [ ] Known-limitations shipped — `docs/KNOWN-LIMITATIONS.md` is in the repo
and `/help` points at it (en + ms).
- [ ] Privacy audit signed — `docs/PRIVACY-AUDIT.md` carries the evidence and
a human signature; `npm run privacy-audit` exits 0.
- [ ] Budget record green — `docs/BUDGETS.md` is in the repo; `npm run
budget-audit` exits 0 and the c3 degradation class is stated honestly.
- [ ] Full device matrix walked — Part A has no FAIL and no NOT RUN.
- [ ] `/help` and `/privacy` accurate — the replies match the shipped
behaviour and the limitations pointer line resolves.
- [ ] Repo pushed public under GPL-3.0 — the remote is public, the root
`LICENSE` is GPL-3.0, and `docs/ROAD-GRAPH.md` + the audit commands are
the fork's onboarding path.
**6. `## Executor notes for the run`.** The known-open items: the `/cari`
menu defect (type the command — the doc says so); the cross-metro c3
degradation (a long cross-metro trip may land on the router-pending card);
the R2 / graph upload state (if the graph is absent, `/api/route` answers
honest nulls and long routes degrade); the live-probe credential caveat; and
the never-fake rule.
**7. `## FINAL VERDICT`.** A part-by-part table (`| Part | Verdict |` with
rows A, B, C, D), left EMPTY for the human to fill, followed by a dated
signature block:
```markdown
---
## FINAL VERDICT — ⬜ NOT YET SIGNED
| Part | Verdict |
| --- | --- |
| A. The consolidated device matrix | ⬜ |
| B. The Android battery record | ⬜ |
| C. The automated half | ⬜ |
| D. The launch checklist | ⬜ |
**Signed:** ______________________ **Date:** ____________
```
Do NOT fill any verdict in this task — the file records the PROTOCOL. The
human fills the verdicts.
</action>
<acceptance_criteria>
- `.planning/phases/07-hardening-private-launch/07-LAUNCH-GATE.md` exists and contains the section headings `## Part A — the consolidated device-matrix walk (D-05)`, `## Part B — the Android battery record (D-04)`, `## Part C — the automated half (re-run at gate time)`, `## Part D — the D-09 launch checklist`, `## Executor notes for the run`, `## FINAL VERDICT`
- `grep -c "device results are never faked" .planning/phases/07-hardening-private-launch/07-LAUNCH-GATE.md` returns ≥ 1
- `grep -c "NOT RUN" .planning/phases/07-hardening-private-launch/07-LAUNCH-GATE.md` returns ≥ 3 (the verdict vocabulary, the battery rule, and the executor note)
- `grep -c "⬜" .planning/phases/07-hardening-private-launch/07-LAUNCH-GATE.md` returns ≥ 20 (the empty result cells — nothing is pre-filled)
- `grep -c "Known-limitations shipped" .planning/phases/07-hardening-private-launch/07-LAUNCH-GATE.md` returns 1 and `grep -c "Repo pushed public under GPL-3.0" .planning/phases/07-hardening-private-launch/07-LAUNCH-GATE.md` returns 1 (the six D-09 checklist items)
- `grep -c "SIGNED" .planning/phases/07-hardening-private-launch/07-LAUNCH-GATE.md` returns ≥ 1 and the file contains NO filled verdict (no `APPROVED` string)
</acceptance_criteria>
<verify>
<automated>export PATH="$HOME/.nvm/versions/node/v24.18.0/bin:$PATH"; cd /home/C-Fu/araharah && test -f .planning/phases/07-hardening-private-launch/07-LAUNCH-GATE.md && grep -c "⬜" .planning/phases/07-hardening-private-launch/07-LAUNCH-GATE.md && ! grep -q "APPROVED" .planning/phases/07-hardening-private-launch/07-LAUNCH-GATE.md && echo "gate file is an empty protocol"</automated>
</verify>
<done>
The launch gate file exists with the full protocol — the 4×7 device
matrix, the battery record fields, the automated-half commands, the six
D-09 checklist items, and an unsigned FINAL VERDICT block.
</done>
</task>
<task type="checkpoint:human-verify" gate="blocking">
<name>task 2: THE CONSOLIDATED WALK + THE BATTERY RUN (the blocking human gate)</name>
<files>.planning/phases/07-hardening-private-launch/07-LAUNCH-GATE.md</files>
<read_first>
- .planning/phases/07-hardening-private-launch/07-LAUNCH-GATE.md (the protocol written in task 1 — the verdict vocabulary, the per-interaction pass conditions, the battery field list, and the six checklist items)
- .planning/phases/06-turn-by-turn-guidance-live-driving/06-DEVICE-GATE.md (the never-fake protocol precedent)
</read_first>
<action>
The human walks the four clients, runs the battery measurement, and fills
the verdict tables IN PLACE in `07-LAUNCH-GATE.md`. No automated step
replaces any of it.
</action>
<acceptance_criteria>
- `grep -c "⬜" .planning/phases/07-hardening-private-launch/07-LAUNCH-GATE.md` returns 0 (every verdict cell is filled — no cell is left blank)
- `grep -c "NOT YET SIGNED" .planning/phases/07-hardening-private-launch/07-LAUNCH-GATE.md` returns 0 (the FINAL VERDICT header was updated to the signed verdict)
- `grep -c "Signed:" .planning/phases/07-hardening-private-launch/07-LAUNCH-GATE.md` returns 1 and the signature line is NOT blank (a name is written)
- `grep -c "Date:" .planning/phases/07-hardening-private-launch/07-LAUNCH-GATE.md` returns ≥ 1 and the date line is NOT blank
- `grep -c "FAIL" .planning/phases/07-hardening-private-launch/07-LAUNCH-GATE.md` returns 0 in the FINAL VERDICT table OR the gate is reported as NOT SIGNED (a FAIL keeps the phase open — the file must not claim a signed state with a failing part)
- the Part B table contains a start %, an end %, and an elapsed-minutes value (the battery record is measured, not blank)
</acceptance_criteria>
<what-built>
Everything automatable in Phase 7: the fullscreen capability layer and its
8.0 fallback (07-01), the landscape cockpit geometry and the failure-state
matrix (07-02), the consolidated privacy audit and its signed record
(07-03), the consolidated budget record and the static-asset cache policy
(07-04), and the paid debts plus the known-limitations document (07-05).
The launch gate file (07-06 task 1) holds the empty protocol: a 4×7
device matrix, the battery record fields, the automated-half command list,
and the six D-09 checklist items.
What is left is exactly what no tool can produce: the physical walk and
the measured battery number.
</what-built>
<how-to-verify>
**Part 0 — the automated half (re-run at gate time, ~2 minutes).** From
the repo root:
```
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
npm run privacy-audit
npm run budget-audit
npm run capture -w web
npm run privacy-audit -- --live
npm run budget-audit -- --live
```
Every command must exit 0. A `--live` probe that prints `NOT RUN` and
exits 2 is NOT a pass — the credentials must be present.
**Part A — the ONE consolidated walk (D-05).** Open the deployed Mini App
from the bot on each of the four clients, and work through all seven
interactions on EACH client: boot · search→route · guidance ·
minimize/resume · trip-end · theme flip · landscape/fullscreen. Fill one
verdict word per cell in Part A of
`.planning/phases/07-hardening-private-launch/07-LAUNCH-GATE.md` — PASS,
FAIL, or NOT RUN — plus a note in the Notes column. The pass conditions
are listed in the file under "What to check per interaction"; read them
before starting. A NOT RUN cell (a device that was not available) keeps
the checklist unsigned — it is not a PASS.
**Part B — the battery run (D-04).** On the Android phone, start a
guidance run and keep the screen on for AT LEAST 30 MINUTES of real
driving (a real route, not a stationary app). Read the battery
percentage immediately before starting and immediately after ending, and
fill every field of Part B: device, OS, Telegram client version,
brightness, network, the screen-on interval, start %, end %, the route,
the elapsed minutes, the computed %/hr, and the caveats. If the run was
shorter than 30 minutes or was interrupted, mark it NOT RUN and leave the
gate open — do not extrapolate.
**Part C — the automated half table.** Fill the result column for each
command.
**Part D — the checklist.** Tick each of the six items only when its
evidence actually exists: the limitations doc is in the repo and `/help`
points at it; `docs/PRIVACY-AUDIT.md` is signed; `docs/BUDGETS.md` is
green and states the c3 class; Part A has no FAIL and no NOT RUN; `/help`
and `/privacy` read accurately against the shipped behaviour; and the
remote repository is public with the root `LICENSE` at GPL-3.0.
**The verdict.** Fill the FINAL VERDICT table (Parts A–D) and the
signature line with your name and the date.
**The launch itself is yours.** Once the checklist is signed, the
milestone completes — the workflow does NOT distribute the bot link. Give
it to your first KL drivers yourself.
</how-to-verify>
<resume-signal>
Type "signed" with the FINAL VERDICT filled (and paste the Part A matrix
and the Part B battery numbers), or describe what failed / which legs were
NOT RUN. On "signed", the executor writes the milestone completion record
into `.planning/STATE.md`. On a FAIL or a NOT RUN, the phase stays open —
open a `/gsd-debug` for the failing symptom and record the NOT RUN legs
honestly.
</resume-signal>
<verify>
<automated>export PATH="$HOME/.nvm/versions/node/v24.18.0/bin:$PATH"; cd /home/C-Fu/araharah && grep -c "SIGNED" .planning/phases/07-hardening-private-launch/07-LAUNCH-GATE.md && ! grep -q "⬜ NOT YET SIGNED" .planning/phases/07-hardening-private-launch/07-LAUNCH-GATE.md && echo "the gate file is signed (the NOT YET SIGNED marker is gone)"</automated>
</verify>
<done>
Every Part A cell carries a PASS/FAIL/NOT RUN verdict with a note; Part B
carries a real ≥30-minute Android battery measurement (or an explicit NOT
RUN); Part C's command table is filled; Part D's six checklist items are
each backed by their named evidence; and the FINAL VERDICT table is
signed with a name and a date.
</done>
</task>
<task type="auto">
<name>task 3: record the milestone completion in STATE.md (after the checklist is signed)</name>
<files>.planning/STATE.md</files>
<read_first>
- .planning/STATE.md (the whole file — the frontmatter fields, the "Current Position" block, the "Accumulated Context" sections, and the "Progress" table)
- .planning/phases/07-hardening-private-launch/07-LAUNCH-GATE.md (the signed FINAL VERDICT — the source of this record)
- .planning/ROADMAP.md (the Phase 7 entry — update its "Plans" count and its plan list)
</read_first>
<action>
ONLY run this task after the checkpoint returned "signed" and the gate file's
FINAL VERDICT table is filled.
In `.planning/STATE.md`:
1. Update the frontmatter `status:` to `complete` and `stopped_at:` to a
sentence naming the signed launch gate (e.g.
`Phase 7 complete — the launch gate is signed (private beta checklist)`).
Update `last_updated:` and `last_activity:` to today's date.
2. Update the `## Current Position` block: `Phase: 7` → `Phase: 7 (complete)`,
`Plan: Not started` → `Plan: Complete`, `Status: Ready to plan` →
`Status: Launch gate signed`.
3. Set the `Progress:` bar to `[░░░░░░░░░░] 100%`.
4. In `## Accumulated Context` → `### Decisions`, ADD a bullet recording the
Phase-7 decisions that bind future work:
- The private launch is a checklist-signed private beta; the launch act is
the maintainer's.
- v1 carries ZERO telemetry — the audit proves an absence; opt-in telemetry
is a post-validation milestone.
- The cross-metro CPU class (c3) is the standing degradation: cross-metro
routes land on the router-pending card; the ALT landmark preprocessing is
the committed next-milestone lever.
5. In `### Blockers/Concerns`, move the resolved entries into a short
"RESOLVED 2026-10-25" note and REMOVE the stale open ones — in particular
the `/cari` menu entry stays listed as a KNOWN Telegram-side defect (with
the workaround), and the Phase-4 "legs UNVERIFIED" entry is replaced by
the Phase-7 walk's recorded verdicts.
6. In `### Deferred Items`, ADD the rows that Phase 7 committed to the v1.x
milestone: ALT landmark preprocessing (c2), the WASM-router track, engine B
(the fully client-side webview router), and opt-in telemetry — each with
"Deferred to v1.x milestone" and "Deferred At: Phase 7 (2026-10-25)".
7. Update `## Session Continuity` — `Last session:` and `Stopped at:` and
`Resume file:` (point at the gate file, since it holds the signed record).
In `.planning/ROADMAP.md`:
1. Mark the Phase 7 entry `- [ ]` → `- [x]` and add `(completed 2026-10-25)`.
2. Replace `**Plans:** TBD` under Phase 7 with `**Plans:** 6 plans`.
3. Fill the Phase 7 plan list with the six plan files and their one-line
objectives, marking them `[x]`.
4. Update the `## Progress` table's Phase 7 row to `6/6 | Complete | 2026-10-25`.
5. Leave every other phase's rows untouched.
Do NOT rewrite the roadmap's success criteria, the goal, or any earlier
phase's text.
</action>
<acceptance_criteria>
- `.planning/STATE.md` frontmatter contains `status: complete`
- `.planning/STATE.md` contains the literal `ZERO telemetry` in the Decisions list
- `.planning/STATE.md` contains the literal `ALT landmark preprocessing` in the Deferred Items table
- `.planning/ROADMAP.md` contains `**Plans:** 6 plans` under Phase 7 and the phase line contains `- [x] **Phase 7:`
- `.planning/ROADMAP.md`'s Phase 7 progress row contains `6/6`
</acceptance_criteria>
<verify>
<automated>export PATH="$HOME/.nvm/versions/node/v24.18.0/bin:$PATH"; cd /home/C-Fu/araharah && grep -c "status: complete" .planning/STATE.md && grep -c "\*\*Plans:\*\* 6 plans" .planning/ROADMAP.md && grep -c "6/6" .planning/ROADMAP.md</automated>
</verify>
<done>
STATE.md records the completed milestone with the binding decisions and the
v1.x deferrals, and ROADMAP.md shows Phase 7 complete with its six plans.
</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| The human report → the milestone record | The launch gate's verdicts are human-supplied observations; they are the only evidence source for the physical properties. |
| The signed checklist → the public launch | Signing asserts the artifact set is honest and complete; a false signature is the worst failure mode in the phase. |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-07-27 | Repudiation | `07-LAUNCH-GATE.md` — a synthesized device verdict or battery number | mitigate | Every cell starts as `⬜`; the vocabulary is PASS/FAIL/NOT RUN with NOT RUN explicitly never counting as PASS; the never-fake law is stated verbatim in the file; the battery rule forbids extrapolating a shorter run; the task's own acceptance criteria assert the file contains NO pre-filled verdict (`APPROVED` absent). A synthesized result is structurally impossible to write by accident. |
| T-07-28 | Tampering | the checklist — signing before the evidence exists | mitigate | The six D-09 items each name their own evidence (the shipped doc, the signed audit record, the budget record, a matrix with no FAIL/NOT RUN, the accurate `/help` and `/privacy` replies, the public GPL-3.0 remote). The `--live` probes reporting `NOT RUN` (exit 2) explicitly keep the checklist unsigned. |
| T-07-29 | Information Disclosure | `07-LAUNCH-GATE.md` — a device/OS/battery record leaking into a public repo | accept | The gate file records a device model, an OS version, a battery percentage and a route description — no identity, no account, no coordinate, no credential. The repo is published public under GPL-3.0 by design (D-09), and this data class is intentionally disclosed for the battery method's reproducibility. |
| T-07-30 | Elevation of Privilege | the milestone completion — the workflow acting as the launch act | mitigate | Task 3 runs only after the checkpoint returns "signed"; the gate file states that the launch act itself is the maintainer's — the workflow produces the evidence and the checklist, never the distribution. STATE.md records the SIGNED checklist, not a distribution. |
</threat_model>
<verification>
Run after all tasks:
```bash
export PATH="$HOME/.nvm/versions/node/v24.18.0/bin:$PATH"
cd /home/C-Fu/araharah
npm run bot:test && npm run web:test && npm run routing:test && npm run search:test
npm run privacy-audit && npm run budget-audit
```
All must exit 0. The gate file's FINAL VERDICT must be filled and signed, and
`.planning/STATE.md` must read `status: complete`.
</verification>
<success_criteria>
- `07-LAUNCH-GATE.md` exists as a complete, unsigned protocol: a 4×7 device matrix, the battery record fields, the automated-half command list, the six D-09 checklist items, the executor notes, and an empty FINAL VERDICT.
- The blocking checkpoint was walked by the human; every matrix cell carries a PASS/FAIL/NOT RUN verdict and the battery run is a recorded ≥30-minute real measurement.
- The six checklist items are each backed by their named evidence, and the FINAL VERDICT is signed with a name and a date.
- `STATE.md` records the milestone completion with the binding decisions and the v1.x deferrals; `ROADMAP.md` shows Phase 7 complete with six plans.
</success_criteria>
<output>
After completion, create `.planning/phases/07-hardening-private-launch/07-06-SUMMARY.md`
</output>
@@ -0,0 +1,106 @@
---
phase: 7
slug: hardening-private-launch
status: draft
nyquist_compliant: false
wave_0_complete: false
created: 2026-10-25
---
# Phase 7 — Validation Strategy
> Per-phase validation contract for feedback sampling during execution.
> Derived from 07-RESEARCH.md `## Validation Architecture`.
---
## Test Infrastructure
| 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 in this phase) |
| **Estimated runtime** | ~60–90 seconds (full suite); audits < 15 s |
---
## Sampling Rate
- **After every task commit:** Run the touched workspace's quick command (`npm test -w web` / `npm test -w bot`); run `npm run privacy-audit` when touching storage/log/network code
- **After every plan wave:** Run the full suite command plus `npm run privacy-audit && npm run budget-audit`
- **Before `/gsd-verify-work`:** Full suite must be green; both audits green; live probes green
- **Max feedback latency:** 90 seconds
---
## Per-task Verification Map
*(Task IDs assigned at planning — 2026-10-25. The `task ID` column names the PLAN TASK that delivers the row; the `Plan` column names the plan file (07-01 … 07-06).)*
| task ID | Plan | Wave | Requirement | Threat Ref | Secure Behavior | Test Type | Automated Command | File Exists | Status |
|---------|------|------|-------------|------------|-----------------|-----------|-------------------|-------------|--------|
| 07-01 t3 | 07-01-PLAN.md | 1 | PX-04.a | T-07-06 | Bot API <8.0 or absent method: guidance starts without fullscreen and remains usable | unit | `npm test -w web` | ❌ W0 (`web/test/fullscreen.test.mjs`) | ⬜ pending |
| 07-01 t3 | 07-01-PLAN.md | 1 | PX-04.b | T-07-06 | Bot API 8.0: `requestFullscreen()` called once at guidance start; failure is non-error | unit | `npm test -w web` | ❌ W0 | ⬜ pending |
| 07-01 t3 | 07-01-PLAN.md | 1 | PX-04.c | T-07-06 | `fullscreenChanged` / `fullscreenFailed` update state without throwing | unit | `npm test -w web` | ❌ W0 | ⬜ pending |
| 07-01 t3 | 07-01-PLAN.md | 1 | PX-04.d | T-07-06 | Safe-area inset changes after fullscreen/rotation re-applied | unit | `npm test -w web` | ❌ W0 | ⬜ pending |
| 07-02 t3 | 07-02-PLAN.md | 1 | PX-04.e | — | Landscape viewport keeps banner/list/card/map usable, no overlap/clipping | browser capture | `npm run capture -w web` | Existing harness; ❌ new landscape legs | ⬜ pending |
| 07-06 t2 | 07-06-PLAN.md | 3 | PX-04.f | — | Fullscreen + landscape on Android/iOS/Desktop/browser | manual device | `07-LAUNCH-GATE.md` device matrix | ❌ W0 gate file | ⬜ pending |
| 07-06 t2 | 07-06-PLAN.md | 3 | PX-04.g | — | One Android guidance run ≥30 min records battery, drains <10%/hr | manual measurement | `07-LAUNCH-GATE.md` battery record | ❌ W0 gate file | ⬜ pending |
| 07-02 t2 | 07-02-PLAN.md | 1 | SC2.b | T-07-05 | Route source error/timeout renders designed pending/no-route state | unit/integration | `npm test -w web` | ❌ W0 (`web/test/failure-state-matrix.test.mjs`) | ⬜ pending |
| 07-02 t2 | 07-02-PLAN.md | 1 | SC2.e | T-07-06 | Older Telegram client without 8.0 methods behaves sanely | unit + manual | `npm test -w web`; gate file | ❌ W0 | ⬜ pending |
| 07-03 t1 | 07-03-PLAN.md | 1 | SC3.a | T-07-02 | No banned persistence API in any production `src` tree | source gate | `npm run privacy-audit` | ❌ W0 (`scripts/privacy-audit.mjs`) | ⬜ pending |
| 07-03 t1 | 07-03-PLAN.md | 1 | SC3.b | T-07-01 | Every Worker KV write carries TTL; cron has zero writes | source + runtime | `npm run privacy-audit` | ❌ W0 | ⬜ pending |
| 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.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 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 |
*Status: ⬜ pending · ✅ green · ❌ red · ⚠️ flaky*
---
## Wave 0 Requirements
*(Assigned to plans at planning — 2026-10-25. Each item names the plan task that creates it.)*
- [ ] `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**
- [ ] `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**
- [ ] `.planning/phases/07-hardening-private-launch/07-LAUNCH-GATE.md` — device matrix, battery method/result, checklist signature → **07-06 t1** (protocol) + **07-06 t2** (the human walk)
- [ ] Framework install: none — use the existing custom runners
---
## Manual-Only Verifications
| Behavior | Requirement | Why Manual | Test Instructions |
|----------|-------------|------------|-------------------|
| Fullscreen + landscape usable on Android/iOS/Desktop real clients | PX-04 (SC1) | No simulated hardware results — honesty protocol | One consolidated walk in `07-LAUNCH-GATE.md`: Android + iOS + Desktop + browser × (boot, search→route, guidance, minimize/resume, trip-end, theme flip, landscape/fullscreen); NOT-RUN legs recorded honestly |
| Battery drain <10%/hr during guidance | PX-04 (SC1) | Real measurement, never synthesized (D-04) | One Android guidance run ≥30 min screen-on; battery % read at start/end; method + caveats written into the gate file |
---
## Validation Sign-Off
- [ ] All tasks have `<automated>` 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
**Approval:** pending