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
This commit is contained in:
+217
@@ -0,0 +1,217 @@
|
|||||||
|
# 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:<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):
|
||||||
|
|
||||||
|
```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: __________________
|
||||||
Reference in New Issue
Block a user