15 KiB
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:
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:<token> + chat:<chat_id>), TTL 900 s |
1,000/day | see the daily estimate | kv-writes |
| KV writes — location pin (chat flow) | 1 write (loc:<chat_id>), TTL 300 s |
1,000/day | see the daily estimate | kv-writes |
| KV writes — guidance envelope (POST /api/guidance) | 1 write (guidance:<id>), 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 oneenv.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 isnowSec % CARD_CADENCE_S < SCAN_TICK_SwithCARD_CADENCE_S = 180andSCAN_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 = 240with a 120 s tick ⇒ 720 lists/day, cards every 4 min) — a deliberate product decision with test updates inbot/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-cpugate 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):
/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-Statusis 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: __________________
RESOLUTION (launch-gate decision, 2025-10-25)
KV-list over-ceiling: RESOLVED via the $0-safe retune. Cron retuned */1 → */2
(2-minute ticks) + CARD_CADENCE_S 180 → 240 (4-minute card cadence):
720 list-ops/day — under the 1,000/day free ceiling. The $0 law holds; no paid
tier. Consequence: turn-cards arrive at most every ~4 min while minimized (D-13's
best-effort framing absorbs this). Tests retuned (GC1/GC2/GC4/GC5*/GC-C timeline);
126/126 green.