# Budgets arah² runs on the Cloudflare **free plan** — the $0 law is a product constraint, not an aspiration. This is the checked-in budget record — the BUDGET RECORD (07-04, D-06): every current number MEASURED (or honestly marked `not measured` / `not measured outside --live`) and set beside its documented free-plan ceiling. Re-derive it with: ```bash npm run budget-audit # the five required gates + the comparison table npm run budget-audit -- --live # + the live cache-header probe (exit 2 without a live origin) ``` The measuring gates live in `scripts/lib/budget-gates.mjs`; the orchestrator is `scripts/budget-audit.mjs`. Nothing in any `src` tree imports either — both are maintainer-time repo tools. | Field | Value | | --- | --- | | Measurement date | 2026-10-24 | | Commit SHA | `bbf003f` (audit run against this tree) | | Measurement host | the maintainer's Linux dev box (shared — documented run-to-run CPU variance ≈ 2x) | | Command | `npm run budget-audit` — `BUDGET AUDIT CLEAN`, exit 0 | --- ## Free-plan ceilings Every ceiling below is a documented Cloudflare limit — the SOURCE column cites the page (`Cloudflare Workers limits` / `Cloudflare KV limits` / `Cloudflare Workers pricing`): developers.cloudflare.com/workers/platform/limits · developers.cloudflare.com/kv/platform/limits · developers.cloudflare.com/workers/platform/pricing. | Resource | Free-plan ceiling | Source | | --- | --- | --- | | Worker HTTP CPU | 10 ms/invocation | Workers limits | | Cron CPU | 10 ms/invocation | Workers limits | | Worker subrequests | 50/request | Workers limits | | Worker requests | 100,000/day | Workers pricing | | Worker memory | 128 MB/isolate | Workers limits | | Worker bundle | 64 MiB uncompressed | Workers limits | | Static assets | free and unlimited requests; 20,000 files/version; 25 MiB/file | Workers limits | | KV reads | 100,000/day | KV limits | | KV writes to different keys | 1,000/day | KV limits | | KV writes to same key | 1/second | KV limits | | KV list requests | 1,000/day | KV limits | | KV storage | 1 GB/account and namespace | KV limits | | KV operations/invocation | 1,000 | KV limits | | Workers Logs Free | 200,000 events/day; 3-day retention | Workers pricing | ## Current measurements `Headroom = budget − current`. A negative headroom is printed as measured — never rounded away, never hidden in a footnote. | Measurement | Current | Budget | Headroom | Evidence | | --- | --- | --- | --- | --- | | A* CPU p95 (local ladder, full 25-pair sample) | 83.989 ms (runs today: 67.5–84.0 ms; documented band 38–93 ms) | 8 ms ladder budget (10 ms free ceiling) | **−76.0 ms** — the accepted c3 class (see below) | `route-cpu` | | Route payload (BA9 gz sum: fixtures + routing/route/guidance sources) | 79,815 gz bytes | 204,800 gz bytes (200 KiB) | +124,985 | `bundle-budget` | | Whole-app bundle (`web/dist/assets/*.js`, gz-summed) | 479,178 gz bytes (3 hashed assets) | 500,000 gz bytes | +20,822 | `bundle-budget` | | Worker bundle (uncompressed) | not measured — `bot/dist` absent (wrangler builds in memory on deploy) | 64 MiB | n/a | `bundle-budget` | | KV writes — session mint (POST /api/session) | 2 writes (`session:` + `chat:`), TTL 900 s | 1,000/day | see the daily estimate | `kv-writes` | | KV writes — location pin (chat flow) | 1 write (`loc:`), TTL 300 s | 1,000/day | see the daily estimate | `kv-writes` | | KV writes — guidance envelope (POST /api/guidance) | 1 write (`guidance:`), TTL 7,200 s | 1,000/day | see the daily estimate | `kv-writes` | | KV writes — **cron guidance tick** | **0 writes** (source-proven) | 1,000/day | full | `kv-writes` | | KV writes — daily estimate (derived) | ≈ 4 writes per guided chat-session (2 mint + 1 envelope + 1 pin share; deletes excluded) — 1,000/day ≈ **250 guided chat-sessions/day**; private beta (≈ 5–10 drivers) is ≪ the ceiling | 1,000/day | large | derived (formula stated) | | KV list requests | **1,440/day derived** (60 s cron × 1 `list` per tick) | 1,000/day | **−440/day — OVER** (discovered finding; see below) | source derivation | | Static asset cache headers (live) | not measured in this run — the deployed origin serves a bundle older than `web/dist` (the probe reads HTTP status first and reports deployment drift); the HTML entry observed revalidating: `cache-control: public, max-age=0, must-revalidate` | the `_headers` policy | — | `cache-headers` (--live) | | Subrequests per request class | see the dedicated table below | 50/request | ≥ +48 per class | `subrequests` | | Golden route corpus (`routing:corpus`) | exit 0 — corpus gate PASS, gz 16,576 B / 102,400 B budget | green (SC4.a) | — | `golden-corpus` | | Golden search corpus (`search:corpus`) | exit 0 — top3 ≥ 90% PASS, refusal margin 0.643 < 0.72 | green (SC4.b) | — | `golden-corpus` | ## Subrequests per request class The 50-subrequests-per-request ceiling has a per-request-class inventory beside it. The counts are a **source-derived inventory, not a live Workers Logs metric**; where the live metric cannot be produced, the row reads `not measured outside --live` and no number is substituted. KV operations are **NOT subrequests** — Cloudflare counts them against the separate KV-operations ceiling (1,000/invocation); they are tracked in the KV-write rows above. | Request class | External subrequests | Budget (50/request) | Headroom | Source | | --- | --- | --- | --- | --- | | POST /telegram/webhook (grammY update) | ≤ 2 per update (reply / photo-with-text-fallback) — update-dependent; not measured live | 50 | ≥ +48 | `bot/src/bot/commands/start.ts` (`ctx.reply` / `replyWithPhoto` fallback) | | POST /api/session | 0 external subrequests | 50 | +50 | `bot/src/webhook.ts` `handleSession` — HMAC validation is local; KV ops are not subrequests | | GET /api/location | 0 external subrequests | 50 | +50 | `bot/src/webhook.ts` `handleLocation` — KV reads only | | POST /api/location-fallback | 1 external subrequest (Telegram sendMessage) | 50 | +49 | `bot/src/bot/handlers/location-fallback.ts` (`bot.api.sendMessage`) | | POST/GET/DELETE /api/guidance | 0 external subrequests | 50 | +50 | `bot/src/webhook.ts` `handleGuidance` — KV only | | POST /api/route | **0 external subrequests** | 50 | +50 | `bot/src/routing/worker-router.ts` — the road graph comes from the bound R2/KV binding, never fetched; the A* search is pure CPU | | scheduled (cron guidance tick) | 1 external subrequest **per DUE envelope** (Telegram sendPhoto) — the per-tick total depends on the number of live due envelopes: **not measured** | 50 | ≥ +49 per envelope | `bot/src/webhook.ts` `runGuidanceCronTick` (`api.sendPhoto`) — **the ONLY recurring external-subrequest class** | The enforcement point is `runSubrequestClassGate`: it derives the route table FROM `bot/src/webhook.ts` and FAILS the audit when a route class has no inventory row — the table must follow the code, not the reverse. ## The cron writes zero The guidance cron tick (`scheduled()`, every minute) performs **zero KV writes** — not "we believe", but a source-proven law: `runKvWriteClassGate` extracts the `runGuidanceCronTick` function body from `bot/src/webhook.ts` and asserts the body contains no `.put(` and no `.delete(`; the evidence prints the count explicitly (`cron write count: 0`). The tick **lists** the `guidance:` prefix, **reads** each envelope defensively, projects the due turn-cards (pure CPU), and **sends** to Telegram — and does nothing else. This matters because the cron is the one code path that runs without a user request: a stray write there would burn the free plan's 1,000-writes/day budget with no user watching (T-07-18). The structural pin also lives in `bot/test/guidance-cron.test.mjs` (the zero-write invariant) — the audit gate is the budget-side re-check of the same law. ## Golden corpora The ONE `npm run budget-audit` command also re-runs the golden corpora — they are never re-implemented, only spawned as child processes by the `golden-corpus` gate, so the budget record and the corpora can never disagree (the VALIDATION map names this gate as the enforcement point for SC4.a / SC4.b): - `routing:corpus` — the golden route pairs (RT1–RT10 classes): **exit 0 in this run** (corpus gate PASS; gz 16,576 B / 102,400 B budget; multi-route pairs 8 / floor 4). - `search:corpus` — the golden search queries (SG vectors + negatives): **exit 0 in this run** (threshold top3 ≥ 90.0% PASS; refusal margin 0.643 < 0.72; negatives refused 7/7). A failing corpus FAILS the gate carrying that child's own failure output verbatim — the record then cannot claim the corpora are green, because the gate just re-proved them. ## The KV list budget — a DISCOVERED over-ceiling finding (07-04) The audit's source derivation found one over-ceiling number that the launch gate must see: - **Current:** the cron trigger is `*/1 * * * *` (60 s scan, D-11's window) and every tick makes exactly one `env.chat_sessions.list(...)` call ⇒ **1,440 list requests/day derived**. - **Ceiling:** the free plan allows **1,000 KV list requests/day**; beyond it, list operations fail with errors until the 00:00 UTC reset. - **Consequence:** every UTC day after ~16 h 40 m, the cron's KV list fails — the tick degrades (the list error surfaces as a waitUntil rejection; no cards fire until UTC midnight). Benign for a morning driver, not benign for an evening one. - **Why this plan does not auto-fix it:** the tick spacing is coupled into the due-card math — `projectTurnCards`' due rule is `nowSec % CARD_CADENCE_S < SCAN_TICK_S` with `CARD_CADENCE_S = 180` and `SCAN_TICK_S = 60`. Moving the trigger to 120 s (D-11's window upper edge, 720 lists/day) breaks the divisibility (`180 % 120 ≠ 0`) and retimes the designed 3-minute card cadence — a product-timing decision, not a config-only change. - **The maintainer's options:** (i) retune the pair (e.g. `CARD_CADENCE_S = 240` with a 120 s tick ⇒ 720 lists/day, cards every 4 min) — a deliberate product decision with test updates in `bot/test/guidance-cron.test.mjs`; (ii) accept the documented degradation; (iii) Workers Paid — breaks the $0 law, requires sign-off. This is recorded as an open decision for the 07-06 launch gate. ## Cross-metro CPU degradation (the c3 class) The honest escalation from Phase 6 (plan 06-04), restated as the record's CPU row: - Short product routes (2–8 km) measure **2–8 ms** on a quiet isolate — inside the 8 ms ladder budget. The full 25-pair sample (the corpus demo-script pairs plus 10 quadrant-sampled cross-city worst cases) measures **p95 38–93 ms** across runs — over budget, and this box's shared-load variance is ≈ 2x. - The mitigation ladder was exhausted WITHIN the $0 law: (a) tighten `DRIVABLE_CLASSES` + rebuild — **rejected on correctness** (12/25 pairs unreachable; the dropped classes are the fine mesh tying landmarks to the arterials), reverted; (b) bidirectional A* — **kept** (provably optimal, no worse on the p95 class, better on short product routes); (c) Workers Paid — **rejected** ($5/mo breaks the $0 law; needs maintainer sign-off). - The DECIDED posture (c3, now): cross-metro CPU exhaust → the Worker's 1102 path → the client's fail-honest null → the **router-pending state** — designed degradation, never a fabricated route. The committed upgrade lever is the **ALT landmark preprocessing** (build-time, still $0 at runtime) — a NEXT-MILESTONE item, not this phase. - Evidence: `routing/scripts/measure-route-cpu.mjs`'s LADDER HISTORY (in-file) and the 06-04 plan summary (`.planning/phases/06-turn-by-turn-guidance-live-driving/06-04-SUMMARY.md`). - The `route-cpu` gate therefore compares the re-run p95 against a 200 ms DRIFT GUARD (≈ 2x the worst documented run): inside the band = the documented accepted state (the 8 ms comparison is still printed, headroom negative); beyond it = uncharted regression and the gate FAILS. ## Static assets The cache policy is `web/public/_headers`, consumed verbatim by the Worker's `[assets]` binding (wrangler `directory: "../web/dist"`; Vite copies `public/` into `dist/`, so the file lands at the served root — verified in this plan): ```text /assets/* Cache-Control: public, max-age=31556952, immutable / Cache-Control: public, max-age=0, must-revalidate ``` - `/assets/*` are Vite's content-hashed outputs — a hashed URL's bytes can never change, so they are immutable for a year (315,569,52 s = 365 days). - 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. - The glyphs/logo (`/glyphs/*`, public copies) are NOT hashed — they stay on the default policy; only `/assets/*` is immutable. - **Observed live headers: not measured in this run** — the deployed origin serves an older bundle, so the probe reports deployment drift (exit-2 class) instead of interpreting a 404's absence of headers as a violation. `CF-Cache-Status` is probabilistic per Cloudflare's own footnote — when observed, it is quoted verbatim, never interpreted as a guarantee. Re-run at the launch gate after the redeploy. ## Statement Measured **in this run**: the A* CPU ladder (local, 25 pairs — a LOCAL measurement; the live Workers Logs CPU number is a DIFFERENT measurement and neither substitutes for the other), both bundle ceilings (BA9 route payload, whole-app app bundle), the KV write classes with the cron's zero-write count, the static-asset file count, and both golden corpora. **Derived from source** (not live): the per-class subrequest inventory, the KV daily write estimate, and the KV list rate (1,440/day — over ceiling; open decision). **Not measured**: the live Worker CPU metric, cron CPU, worker memory, the Worker bundle (`bot/dist` absent), KV reads/writes/storage against their daily ceilings, KV operations/invocation, Workers Logs volume, and the live cache headers on a CURRENT deploy (deployment drift). Nothing is estimated optimistically; every negative headroom is printed where it was found. The record is re-derived at the launch gate (07-06) after the redeploy, when the live header probe can observe the shipped `_headers` policy. ## Signature The launch gate (07-06) signs this record after the redeploy re-run: - Maintainer: __________________ - Date: __________________