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

465 lines
25 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: 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>