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

This commit is contained in:
araharah-executor
2026-10-25 02:31:50 +08:00
parent 0d437db10c
commit 27b340fa6b
7 changed files with 3106 additions and 0 deletions
@@ -0,0 +1,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>