Files
arah2/docs/BUDGETS.md
T
araharah-executor 887b446955 docs(07-04): the checked-in BUDGET RECORD — real measured numbers beside the free-plan ceilings (D-06)
- the full ceilings table (Cloudflare Workers limits / KV limits / pricing, sourced)
- current measurements: A* p95 83.989 ms vs 8 ms ladder budget (the accepted c3 class,
  negative headroom printed), BA9 79,815/204,800 gz, app bundle 479,178/500,000 gz,
  KV write classes (cron = 0, source-proven), golden corpora exit 0
- the per-request-class subrequest inventory (source-derived, not a live Workers Logs metric)
- the DISCOVERED finding: KV list rate 1,440/day (60 s cron x 1 list/tick) OVER the
  1,000/day ceiling — documented with options, decision deferred to the 07-06 launch gate
- the c3 cross-metro escalation restated; honest Statement; unsigned signature block
2026-10-25 05:06:30 +08:00

14 KiB
Raw Blame History

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 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):

/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: __________________