docs(07): plan phase 07 — hardening + private launch (6 plans, 3 waves)
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user