25 KiB
phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_have_notes, must_haves
| phase | plan | type | wave | depends_on | files_modified | autonomous | requirements | must_have_notes | must_haves | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 07-hardening-private-launch | 04 | execute | 2 |
|
|
true |
|
|
|
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).
<execution_context> @$HOME/.config/opencode/get-shit-done/workflows/execute-plan.md @$HOME/.config/opencode/get-shit-done/templates/summary.md </execution_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 The measurement header law — from `routing/scripts/measure-route-cpu.mjs` lines 3–9 (the budget audit inherits this shape): * 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:
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:
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:
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):
/assets/*
Cache-Control: public, max-age=31556952, immutable
/
Cache-Control: public, max-age=0, must-revalidate
# 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
_headersformat (path pattern line, then indentedHeader: valuelines). - The
Cache-Controlvalues are EXACTLY as written above (31556952seconds = 365 days;max-age=0, must-revalidate). - The glyphs, the logo files, and the
/glyphs/*PNGs are NOT hashed by Vite (they are staticpublic/copies) — do NOT give them the immutable rule; leave them on the default policy. Only/assets/*getsimmutable. - Add NO other header rule. No
Set-Cookie, no CSP, noAccess-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.
<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>
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
The _headers policy exists in web/public/, reaches web/dist/, and
carries exactly the two rules (hashed assets immutable, HTML revalidating).
It MUST export:
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 FAILline 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.binis 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/invocationis the ceiling; the gate printsp95 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/*.jsand 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/distexists; 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 therunGuidanceCronTickfunction body (fromasync 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/*.tsfiles with a regex onexport 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).
<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>
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=mk;console.log(r.id+': '+(r.notRun?'NOT RUN':(r.pass?'PASS':'FAIL'))+' — '+String(r.evidence).slice(0,160));}})"
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.
Behaviour:
- Accepts an optional
--liveflag. Without it, the cache-header gate is SKIPPED and reported asNOT RUN (pass --live). - Runs
runCpuLadderGate(),runBundleBudgetGate(),runKvWriteClassGate(); with--live, alsorunCacheHeaderGate(). - 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 measuredwhere it did not. This table is the console form ofdocs/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:
0all required gates passed;1at least one gate FAILED;2environment 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:
"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:
- A title
# Budgetsand a dated front-matter block naming the measurement date, the commit SHA (git rev-parse --short HEAD), and the measurement host. - A
## Free-plan ceilingstable — EVERY row from 07-RESEARCH.md §Pattern 5, with the SOURCE cited (Cloudflare Workers limits / KV limits / pricing). - A
## Current measurementstable — 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. - A
## The cron writes zerosection — one honest paragraph explaining WHY (the tick lists, reads, and sends; it never writes), and naming the source gate that proves it. - 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) tightenDRIVABLE_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). Citerouting/scripts/measure-route-cpu.mjs's LADDER HISTORY and the 06-04 plan summary as the evidence. - A
## Static assetssection — the_headerspolicy and the observed live headers (or an honest "not measured in this run"). - A
## Statementsection — 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. - A
## Signatureblock 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.
<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>
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=$?"
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.
<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> |
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.
<success_criteria>
web/public/_headersexists and reachesweb/dist/, carrying the immutable hashed-asset rule and the revalidating HTML rule, and nothing else.npm run budget-auditmeasures 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.mdcarries the record with the real numbers, the c3 cross-metro degradation class, the cron zero-write explanation, and a human signature block. </success_criteria>