Files
arah2/.planning/phases/07-hardening-private-launch/07-PATTERNS.md
T

366 lines
27 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Phase 7: Hardening + Private Launch - Pattern Map
**Mapped:** 2026-10-25
**Files analyzed:** 16 (9 new, 7 modified/re-run)
**Analogs found:** 14 / 16 (2 have no in-repo analog: `_headers`, cloudflare live probes)
## File Classification
| New/Modified File | Role | Data Flow | Closest Analog | Match Quality |
|-------------------|------|-----------|----------------|---------------|
| `scripts/privacy-audit.mjs` + `scripts/lib/source-gates.mjs` | utility (CLI gate tool) | batch (scan → report → exit) | `web/test/no-storage.test.mjs` + `bot/test/route-privacy.test.mjs` (scans) + `theme/scripts/audit.mjs` (CLI/exit codes) | exact |
| `scripts/lib/bundle-gates.mjs` | utility | batch | `web/test/build-artifact.test.mjs` (BA1/BA5/BA8/BA9) | exact |
| `scripts/lib/cloudflare-probes.mjs` | utility (live probe) | request-response (CLI → wrangler) | — no in-repo live probe exists (see No Analog) | role-match |
| `scripts/budget-audit.mjs` | utility (CLI measurement gate) | batch | `routing/scripts/measure-route-cpu.mjs` + BA9 | exact |
| `web/test/fullscreen.test.mjs` | test | event-driven | `web/test/homescreen.test.mjs` | exact |
| `web/test/failure-state-matrix.test.mjs` | test | event-driven (named replay vectors) | `web/test/guidance-replay.test.mjs` + `web/test/guidance-surface.test.mjs` | exact |
| `web/public/_headers` | config | n/a (static) | — none (Cloudflare doc format; RESEARCH §Code Examples) | no analog |
| `web/src/telegram/fullscreen.ts` (new) | service (capability wrapper) | event-driven | `web/src/guidance/close-protection.ts` + `web/src/telegram/homescreen.ts` | exact |
| `web/src/telegram/webapp.ts` (modify — grow contract) | model (typed contract) | n/a | itself — documented optional-member growth law | exact (self) |
| `web/src/shell.css` (modify — landscape media queries) | component (stylesheet) | transform | itself — token-var discipline + `[data-guidance]` block | exact (self) |
| `web/src/telegram/safe-area.ts` (modify — re-resolve on inset events) | service | event-driven | itself — resolve/apply pair | exact (self) |
| `bot/test/initdata.test.mjs` (modify — exact-900s vector, IN-03) | test | request-response | itself — T4 boundary vectors | exact (self) |
| `bot/src/security/initdata.ts` (modify — IN-01 debt) | model (security) | transform | itself — dead `ParsedInitData` export | exact (self) |
| `bot/wrangler.jsonc` (modify — IN-02 stale comment) | config | n/a | itself — line 2 | exact (self) |
| `bot/src/i18n/strings.ts` (modify — /help limitations pointer) + `bot/src/bot/commands/help.ts` | model (copy) | n/a | itself — `helpMenu` + D-05 law | exact (self) |
| `docs/KNOWN-LIMITATIONS.md` (new) | doc | n/a | `docs/CONFIGURATION.md` | role-match |
| `.planning/phases/07-hardening-private-launch/07-LAUNCH-GATE.md` (new) | planning doc | n/a | `.planning/phases/06-.../06-DEVICE-GATE.md` | exact |
| `bot/scripts/register-commands.mjs` (re-run only, never modified) | utility | n/a | not modified — copy derives from `strings.ts` (CS5 pins this) | n/a |
## Pattern Assignments
### `scripts/privacy-audit.mjs` + `scripts/lib/*` (utility, batch)
**Analogs:** `web/test/no-storage.test.mjs` (client storage gate), `bot/test/privacy.test.mjs` (KV TTL gate), `bot/test/route-privacy.test.mjs` (host/import allowlists), `theme/scripts/audit.mjs` (CLI + exit-code contract), `web/test/build-artifact.test.mjs` (bundle gates)
The repo's gate lineage to consolidate: GEO3 (web memory-only) ← PG1/PG2 (Worker TTL writes) ← RP1/RP2 (Worker routing shape) ← BA1/BA5/BA8 (bundle). The audit tool extends the same laws repo-wide, it does not invent new ones.
**Comment-stripped source scan** — copy verbatim from `web/test/no-storage.test.mjs` lines 29–42 (identical twins exist in `bot/test/route-privacy.test.mjs` lines 42–59 and `web/test/homescreen.test.mjs` lines 240–244):
```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)
}
```
> Planner note: the `(^|[^:\\])` guard keeps `https://` URLs alive; the header comment says the over-stripping is deliberate "prefer-fail reading". Reuse exactly; do not write a smarter stripper (RESEARCH Pitfall 6).
**The banned-vocabulary shape** — `web/test/no-storage.test.mjs` lines 22–25 + the assertion style (lines 50–59):
```javascript
const BANNED = [
"localStorage", "sessionStorage", "indexedDB", "caches", "CacheStorage",
"document.cookie", "WebApp.DeviceStorage", "WebApp.SecureStorage", "WebApp.CloudStorage",
];
// per file, per token:
assert.ok(!code.includes(banned), `${path.relative(process.cwd(), file)} references ${banned} — GEO-03 violated`);
```
**The KV write-TTL law the audit must extend repo-wide** — `bot/test/privacy.test.mjs` lines 32–49 (PG1): every `.put(` line carries `expirationTtl` ON THE SAME LINE, and it must reference a NAMED constant, never an inline literal:
```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`);
}
});
```
**Positive host allowlist (prefer allow over ban for runtime fetches)** — `bot/test/route-privacy.test.mjs` lines 94–105 show the negative-host form; BA1 lines 64–70 show the CDN ban list. D-01 flips this to a POSITIVE allowlist (OpenFreeMap tiles, Telegram API, own Worker) — keep the per-file `assert` message style from RP1c:
```javascript
const banned = /(overpass|project-osrm|osrm|valhalla|graphhopper|openrouteservice|routing\.openstreetmap)/i;
// ... per file: assert.ok(hit === null, `${relative} references routing-host "${hit?.[0]}" — <the law and why>`);
```
**CLI + exit-code contract** — `theme/scripts/audit.mjs` lines 20–24 define the repo's audit vocabulary (reuse it — OpenCode's discretion area explicitly covers exit codes, and this precedent already exists):
```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)
```
And its driver shape (lines 797–814): print every violation, then a single `AUDIT FAILED — N …` / `AUDIT CLEAN — …` summary line, then `process.exit(0|1)`. RESEARCH Pattern 4 adds exit **2 = missing credentials/environment** so a skipped live probe is never mistaken for a pass.
**Bundle-scan mechanics** — `web/test/build-artifact.test.mjs`: the DirEnt walk (lines 79–87), the PRODUCTION-only scope law (BA5 lines 133–147 — `web/dist-harness/` is deliberately NOT clean; a gate pointed at it inverts itself), and BA8's JS-assets-only rule for crypto scans (lines 175–187 — the official Telegram SDK script in `index.html` is EXTERNAL and has its own crypto; the HTML fetch-host allowlist must whitelist `telegram.org`'s script tag explicitly, RESEARCH Pitfall 5/A4).
---
### `scripts/budget-audit.mjs` + `scripts/lib/*` (utility, batch)
**Analogs:** `routing/scripts/measure-route-cpu.mjs` (the measurement half — RE-RUN, not rewritten), `web/test/build-artifact.test.mjs` BA9 (the print-the-budget law)
**Header contract** — `routing/scripts/measure-route-cpu.mjs` lines 3–9 set the law the budget audit inherits:
```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).
```
**Print-then-gate** — BA9, `web/test/build-artifact.test.mjs` lines 221–234:
```javascript
const total = inputs.reduce((sum, f) => sum + gzipSync(readFileSync(f)).length, 0);
/* The measurement is VISIBLE on every run (D-11: "enforced as a BA
* build-artifact gate" — a budget nobody sees is a hope, not a gate). */
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}. …`);
```
**Gate-fail message carries the mitigation ladder** — `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`);
process.exit(0);
```
The budget audit re-runs `node routing/scripts/measure-route-cpu.mjs` (do not fork its logic — D-06/RESEARCH "Don't Hand-Roll") and adds: gzip'd bundle bytes (BA9 inputs: `routing/src`, `web/src/route`, `web/src/guidance`, `route-fixtures.json` — lines 204–209), KV write classes per request source (`session:`/`chat:` at `SESSION_TTL_SECONDS` — `bot/src/kv/sessions.ts` line 43; `loc:` at `PIN_TTL_SECONDS` — `location-pin.ts` line 25; `guidance:` at `GUIDANCE_SESSION_TTL_SECONDS` — `guidance-sessions.ts` line 27; cron = 0 writes per `webhook.ts` lines 49–57), and live asset cache headers once `_headers` ships. Reference ceilings table is in RESEARCH §Pattern 5.
---
### `web/test/fullscreen.test.mjs` (test, event-driven)
**Analog:** `web/test/homescreen.test.mjs` — the WHOLE file is the template (fake WebApp + minimal DOM stub + guard tests + source-scan ban). Key excerpts:
**Injected fake, per-case install/restore** (lines 5–8, 87–127):
```javascript
/* … driven with INJECTED FAKES (the location-chain.test.mjs style: build the
fake, call, assert — globals stubbed per case and restored in a finally
block, never leaked). */
function fakeWebApp(opts = {}) {
const calls = { addToHomeScreen: 0, checkedStatus: false };
const events = new Map();
const wa = {
initData: "",
version: opts.version ?? "10.0",
isVersionAtLeast(v) { /* segment-wise numeric compare, lines 93–103 */ },
close() {},
onEvent(event, cb) { /* push into events map */ },
emitEvent(event) { /* fire registered callbacks — the test's trigger */ },
};
if (!opts.noAddToHomeScreen) { wa.addToHomeScreen = () => { calls.addToHomeScreen += 1; }; }
wa.__calls = calls;
return wa;
}
```
**The guard's BOTH-halves vectors** — HS1/HS2 (lines 135–156) map 1:1 to fullscreen's PX-04.a/b:
```javascript
test("HS1 a client without addToHomeScreen is a no-op — nothing thrown, no affordance", () => { /* method absent → no-op */ });
test("HS2 an old client (isVersionAtLeast 8.0 false) is a no-op", () => { /* version 7.9 → no-op, "the status was never even queried" */ });
```
**Throwing-platform-call-is-non-error** — HS7 (lines 214–219) is the exact shape for `requestFullscreen()` rejection:
```javascript
wa.addToHomeScreen = () => { throw new Error("platform refused"); };
assert.doesNotThrow(() => btn.dispatch("click"), "a rejected addToHomeScreen must not break the boot");
```
**The source-scan ban closer** — HS9 (lines 240–248): end the suite with a comment-stripped scan of the new module banning the anti-pattern APIs (here: `Element.requestFullscreen` as primary path, `screen.orientation.lock`).
Test IDs should follow the repo's prefix convention (`HS*`, `GS*`, `R*` → use e.g. `FS*`).
---
### `web/test/failure-state-matrix.test.mjs` (test, named vectors)
**Analogs:** `web/test/guidance-replay.test.mjs` (named-vector header + pure-reducer replay) and `web/test/guidance-surface.test.mjs` (DOM state-token assertions).
**Named-vector discipline** — `guidance-replay.test.mjs` header lines 15–33 enumerates cases R1–R12/T1–T3 with the law each pins; RESEARCH Pattern 3's `FAILURE_VECTORS` array is the data-driven form. The failure matrix test should import the REAL modules (`../src/route/route.ts`, `../src/guidance/guidance.ts`) and assert on the designed state tokens, e.g.:
```javascript
const g = await import("../src/guidance/guidance.ts");
const { createGuidanceSession, stepGuidance, OFF_ROUTE_THRESHOLD_M /* …named constants */ } = g;
```
**DOM state-token assertions** — `guidance-surface.test.mjs` style: drive the REAL builders through the minimal document fake (`makeEl`, lines 75+), assert `dataset`/`data-*` states ("never blanks mid-swap (D-04/D-06)", GS1). Existing behavior to COVER (not change — RESEARCH Pattern 3 "audit coverage, not behavior"): route `null`/throw already renders the pending card (`web/src/route/route.ts`); off-road is already a calm state machine (`web/src/guidance/guidance.ts` — grace, storm guard, `no_reroute`).
---
### `web/src/telegram/fullscreen.ts` (service, event-driven) — new module
**Analogs:** `web/src/guidance/close-protection.ts` (engage-at-guidance-start, version-gated) + `web/src/telegram/homescreen.ts` (guard both halves, onEvent, try/catch) + `web/src/telegram/webapp.ts` (contract growth).
**The capability-guard pattern, verbatim shape** — `close-protection.ts` lines 21–31:
```typescript
export function engageCloseProtection(wa: TelegramWebAppLike | null): void {
// layer 1 — Bot API 7.7+: gate BEFORE the call, then the ?. chain
if (wa !== null && typeof wa.isVersionAtLeast === "function" && wa.isVersionAtLeast("7.7")) wa?.disableVerticalSwipes?.();
// layer 2 — Bot API 6.2+: the native-close net, gated the same way
if (wa !== null && typeof wa.isVersionAtLeast === "function" && wa.isVersionAtLeast("6.2")) wa?.enableClosingConfirmation?.();
}
```
For fullscreen the guard is BOTH halves (method presence AND 8.0) — `homescreen.ts` line 33 is the tightest form:
```typescript
if (typeof wa.addToHomeScreen !== "function" || !wa.isVersionAtLeast("8.0")) return;
```
plus the best-effort try/catch (homescreen.ts lines 51–58) and the event observation (`wa.onEvent?.("homeScreenAdded", hide)` — line 64 → `fullscreenChanged`/`fullscreenFailed` for fullscreen).
**Wiring site:** `web/src/route/route.ts` calls `engageCloseProtection(deps.wa ?? null)` at the guidance-start edge (lines 341, 616) and `releaseCloseProtection` at trip end (lines 481, 692) — `requestGuidanceFullscreen(wa)` / `exitGuidanceFullscreen(wa)` ride the SAME two edges. Note RESEARCH Pitfall 1: the guidance-start transition (not an async callback) is where the call belongs.
**Contract growth law** — `webapp.ts` header lines 16–27 (must be extended IN THE SAME CHANGE as the fakes):
```text
* Phase 6 (PX-03/D-08) adds the guidance guards as OPTIONAL members only:
* … — the contract stays the contract, no existing member changes shape,
* every test fake and the harness mock satisfy exactly these members.
```
Add `requestFullscreen?`, `exitFullscreen?`, `isFullscreen?`, `lockOrientation?`, `unlockOrientation?` (all 8.0-optional) to `TelegramWebAppLike` (lines 35–86), and mirror them in BOTH fakes: `web/src/mock/telegram-mock.ts` and the per-test `fakeWebApp()` builders ("contract + fake + test together" law).
---
### `web/src/shell.css` landscape media queries + `web/src/telegram/safe-area.ts` re-resolution
**Analog:** the file itself. The token-var discipline is declared in `shell.css` lines 2–18 ("Every value is a token reference…; the only literals are the machined-border geometry the UI-SPEC enumerates") — a landscape block may add geometry literals ONLY if the UI-SPEC-style sanctioned list is extended in the header comment (precedent: the Phase 6 block lines 141–147 opens with exactly such a list: "banner min-height 96px, arrow plate 48px…").
**The state-attribute pattern** (no new markup): `[data-guidance="active"] .strip { … }` (lines 174–181) — landscape rules hang off `@media (orientation: landscape)` combined with the same token vars and `--safe-left/right`:
```css
[data-guidance="active"] .strip {
top: calc(var(--safe-top, 0px) + var(--spacing-space-sm) + 96px + 1px);
min-height: 24px;
padding-top: var(--spacing-space-xs); padding-bottom: var(--spacing-space-xs);
}
```
**Safe-area re-resolution** — `safe-area.ts` already exposes the pair: `resolveSafeInsets(wa)` (lines 35–50, env-union-Telegram with `Math.max` per edge and the old-client pure-env fallback) and `applySafeArea(insets)` (lines 52–58, writes `--safe-top/bottom/left/right` on `:root`). The fullscreen/orientation change path re-CALLS these on `safeAreaChanged`/`contentSafeAreaChanged` (RESEARCH Pitfall 3) — extend, don't duplicate (webapp.ts gains the two event names as `onEvent` usages; the union logic is untouched).
**CSS gate:** every new declaration must pass `npm run audit -w theme` (the DSGN-01 evidence command) — zero radius, no shadows/blur, tokens only.
---
### `bot/test/initdata.test.mjs` — exact-900s boundary vector (IN-03) + `bot/src/security/initdata.ts` debt (IN-01)
**Analog:** the file itself. T4 (lines 172–189) pins 899 → ok / 901 → stale; the comparator at `initdata.ts` lines 193–196 is strictly-greater:
```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 missing vector is the INCLUSIVE edge: age exactly **900 s → ok:true**. Copy the T4 harness shape verbatim (`signedUser(now - 900)` + `loadValidator()` lazy import, lines 43–93). The comment on line 193 already promises "900 ok" — the test makes the promise a pin. Also pin `maxAgeSeconds` override behavior if exercised (opts line 71–73).
**IN-01 targets** (same file, `initdata.ts`):
- Dead export: `ParsedInitData` (lines 59–66) — declared, never constructed or returned (the return type is `InitDataResult`, lines 75–83). Fix per the repo's debt style: either delete the interface or re-point it with an intent comment naming why it stays (prefer deletion; the review finding says "dead").
- Unreachable branch + read-helper intent comments: `parseFields` (lines 87–104) and the user/chat parse blocks (lines 154–165, 198–214) — add the intent comments documenting WHY each catch keeps raw/undefined (tampered-vs-malformed precedence, line 25–26 documents the vocabulary).
**IN-02:** `bot/wrangler.jsonc` line 2 — `// KV namespace id is filled in plan 03 task 1 after …` — is stale: the id IS filled (line 36). Replace with a present-tense comment in the file's own voice (see lines 9–11, 23–29 for the comment register).
---
### `bot/src/i18n/strings.ts` — `/help` limitations pointer (en/ms)
**Analog:** `strings.ts` itself — the D-05 law ("Every user-facing string the bot sends lives here and NOWHERE else", lines 3–7) and `help.ts` (`helpReplyText` lines 18–20 returns `strings[locale].helpMenu` verbatim).
The pointer line is appended to BOTH `helpMenu` strings (en line 94–95, ms line 132–133). Clinical+honest tone per the module header (lines 9–11): "no emoji, no marketing flourishes".
> **Planner gotcha (CS5):** `bot/test/commands.test.mjs` `menuEntries()` (lines 134–144) parses every helpMenu line STARTING WITH `/` and requires the `" — "` marker; CS5 (lines 146–155) asserts the parsed command list is EXACTLY `["help", "privacy", "cari"]`. The limitations pointer line must therefore NOT start with `/` (e.g. `Known limits: <url>` / `Had panduan: <url>` is safe — it is filtered out of the menu parse). If the copy instead extends an existing `/help` line, CS5 still passes (the marker stays); a NEW `/command`-shaped line fails CS5 by design.
>
> The URL should be a single constant per RESEARCH Open Question 5 (deployment config or one strings-module constant — never scattered). `register-commands.mjs` derives from `helpMenu` via `menuFor()` — a non-`/` line stays out of the registered command descriptions automatically, so the re-run remains copy-safe.
---
### `docs/KNOWN-LIMITATIONS.md` (new doc)
**Analog:** `docs/CONFIGURATION.md` — clinical engineering prose, dense tables with a leading "surfaces, by kind" table (lines 9–22), per-row `File | Applies to` scoping, backticked identifiers, bold for requirements, and a strong claim-surface discipline (each statement names its evidence). It joins the same directory (`docs/`) alongside CONFIGURATION/DEVELOPMENT/ROAD-GRAPH/TESTING. Content map from D-08: OSM data gaps · cross-metro CPU degradation (c3, cite `measure-route-cpu.mjs` LADDER HISTORY lines 30–40 as the evidence) · /cari menu defect + workaround · beta quality statement · roadmap pointers (WASM-router, ALT, engine B). Written for drivers AND forks.
---
### `.planning/phases/07-hardening-private-launch/07-LAUNCH-GATE.md` (new gate file)
**Analog:** `.planning/phases/06-turn-by-turn-guidance-live-driving/06-DEVICE-GATE.md` — copy its structure:
1. **Blocking status header** (lines 3–8): what is already green (suite counts + commands), the never-fake law verbatim — *"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"*.
2. **Part tables** (lines 32–43): `| # | Step | Verify |` rows — here: the consolidated D-05 walk (Android + iOS + Desktop + browser × boot / search→route / guidance / minimize-resume / trip-end / theme flip / landscape-fullscreen) and the D-04 battery record (device/OS, brightness, network, screen-on interval ≥30 min, start/end %, route, computed %/hr, caveats — RESEARCH Pitfall 11's field list).
3. **Executor notes** (lines 59–68): known-open items, R2/credentials caveats, and the honesty rule — *"if a step cannot be executed … mark it NOT RUN — do not invent an outcome"*.
4. **FINAL VERDICT section** (lines 72–82): part-by-part table + dated signature; the D-09 launch checklist (known-limitations shipped · privacy audit signed · budget record green · device matrix walked · /help + /privacy accurate · repo public GPL-3.0) signs here.
---
## Shared Patterns
### Capability guard: BOTH halves + `?.` chain + no-throw
**Source:** `web/src/guidance/close-protection.ts` lines 21–31; `web/src/telegram/homescreen.ts` line 33, lines 51–58
**Apply to:** `web/src/telegram/fullscreen.ts`, the `webapp.ts` contract growth, every fullscreen test vector
```typescript
if (wa !== null && typeof wa.isVersionAtLeast === "function" && wa.isVersionAtLeast("8.0")) wa?.requestFullscreen?.();
// a throw is caught and ignored — degradation is a supported state, never an error surface
```
### Contract + fake + test together
**Source:** `web/src/telegram/webapp.ts` header lines 16–27; `web/src/mock/telegram-mock.ts`; `web/test/homescreen.test.mjs` `fakeWebApp()`
**Apply to:** adding fullscreen members — `webapp.ts` optional members, the harness mock, and every test fake change in ONE commit.
### Comment-stripped source introspection (prefer-fail reading)
**Source:** `web/test/no-storage.test.mjs` lines 29–46 (canonical); twins in `route-privacy.test.mjs` 42–59, `homescreen.test.mjs` 58–66
**Apply to:** `scripts/lib/source-gates.mjs` — and to any new structural test (HS9/GS6 style).
### Gate vocabulary: print the measurement, exit 0/1/2
**Source:** `theme/scripts/audit.mjs` lines 20–24, 797–814; `measure-route-cpu.mjs` lines 180–185; BA9 lines 226–233
**Apply to:** both audit CLIs. 0 = pass, 1 = gate failure (evidence printed), 2 = environment/credential drift (never mistaken for pass). RESEARCH Pattern 4 is the orchestration shape:
```javascript
for (const result of results) console.log(`${result.id}: ${result.pass ? "PASS" : "FAIL"} — ${result.evidence}`);
process.exit(results.every((r) => r.pass) ? 0 : 1);
```
### Category-only log vocabulary
**Source:** `bot/src/webhook.ts` lines 63–68 (the law), lines 103/234/244/272/303/318/380/388 (every call site)
**Apply to:** the privacy audit's log-vocabulary gate — the scan asserts every `console.log` in `bot/src` matches the `[surface] verdict: category` shape and that no line can carry coordinates/ids/payloads. The fullscreen fallback path must also stay category-only (RESEARCH Pattern 1 required behavior).
### Named TTL constants + the KV key families
**Source:** `bot/src/kv/sessions.ts` line 43 (`session:`+`chat:` @ 900s), `location-pin.ts` line 25 (`loc:` @ 300s), `guidance-sessions.ts` line 27 (`guidance:` @ 7200s); PG1 same-line law
**Apply to:** the KV listing probe — assert every live key family is one of these four AND carries an `expiration` (RESEARCH A5/Pitfall 7: parse `expiration`, cursor, `list_complete`; report unexpected families, don't assume absence). Probe syntax (already used manually in 06-DEVICE-GATE.md step 7): `npx wrangler kv key list --namespace-id=59f58a54bc44483e89872b20a005a5eb --prefix "guidance:"`.
### Copy lives in strings.ts only (D-05)
**Source:** `bot/src/i18n/strings.ts` lines 3–7; `commands.test.mjs` CS5 lines 157–187 (register script must not inline copy)
**Apply to:** the /help pointer line + `docs/KNOWN-LIMITATIONS.md` link constant.
### Device-gate honesty protocol
**Source:** `06-DEVICE-GATE.md` lines 3–8, 67–68
**Apply to:** 07-LAUNCH-GATE.md — PASS/FAIL/NOT RUN with client version; a NOT RUN leg never blocks as fake PASS; battery numbers are recorded, never synthesized (D-04).
## No Analog Found
| File | Role | Data Flow | Reason | Planner guidance |
|------|------|-----------|--------|------------------|
| `web/public/_headers` | config | static | No static-asset config file exists anywhere in the repo (`web/public/` holds only glyphs/logos) | Use RESEARCH §Code Examples "Static Asset Cache Rules" verbatim (`/assets/*` → `public, max-age=31556952, immutable`; `/` → `public, max-age=0, must-revalidate`); verify live with the budget-audit `--live` probe; keep `web/dist` Vite output shape in mind (hashed `/assets/*`) |
| `scripts/lib/cloudflare-probes.mjs` | utility | request-response (live) | Every existing gate is static/source-level; the only live-KV precedent is a manual command recorded in 06-DEVICE-GATE.md step 7 | Follow `measure-route-cpu.mjs`'s maintainer-time-tool header law + theme-audit exit codes; `--live` flag gates the probe; missing credentials → exit 2 with the gate marked NOT RUN (never pass) |
## Metadata
**Analog search scope:** `web/src/`, `web/test/`, `web/scripts/`, `bot/src/`, `bot/test/`, `bot/scripts/`, `routing/scripts/`, `theme/scripts/`, `docs/`, `.planning/phases/{03,04,06,07}/`, root `package.json`
**Files read in full or in part:** 24 (no-storage, route-privacy, privacy, build-artifact, homescreen.ts/.test, harness.mjs ×2, runner.mjs, webapp.ts, close-protection.ts, safe-area.ts, shell.css, guidance-replay (head), guidance-surface (head), measure-route-cpu, theme/audit.mjs, initdata.ts, initdata.test.mjs, strings.ts, help.ts, wrangler.jsonc, webhook.ts, commands.test.mjs (CS5 region), CONFIGURATION.md (head), 06-DEVICE-GATE.md)
**Pattern extraction date:** 2026-10-25