Files
arah2/docs/ARCHITECTURE.md
T

840 lines
51 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!-- generated-by: gsd-doc-writer -->
# arah² — Architecture
## System Overview
arah² is a Telegram-first GPS navigator: a grammY bot (`arah2bot`) running as a single
Cloudflare Worker serves both the Telegram webhook **and** the static Mini App bundle
served from the same origin. The Mini App (`@araharah/web`, a Vite + MapLibre GL
application) authenticates with the Worker by posting its verbatim
`Telegram.WebApp.initData` to `POST /api/session`, receives a short-lived opaque bearer
token, and then acquires a position through a three-link chain (Telegram
`LocationManager` → WebView `navigator.geolocation` → chat-relayed pin). Searching a
destination and routing to it are real today: `@araharah/search` is the in-Worker
gazetteer (search backend #1 of the D-01 ladder), and `@araharah/routing` is the
routing data plane — its CSR road-graph codec, snapper, and A\* power **engine A**,
the Worker-side router (`bot/src/routing/worker-router.ts`) that routes over a
ROAD_GRAPH R2-hosted KL road-graph binary through `POST /api/route`. Guidance is
end-to-end: a turn-card cron (the repo's first Cron Trigger, `*/2 * * * *`) pushes
turn-cards into the chat from a TTL-only guidance envelope, statelessly. All durable
state is TTL-only — one Cloudflare KV namespace (`chat_sessions`, plus `guidance:*`
envelopes in it) — so the system has no database, no persistent storage, and no
analytics. `@araharah/theme` is the shared design-token package: it compiles Tailwind
v4 CSS consumed by the web app and is the derivation source for the Rams basemap style
the map renders.
Architecturally this is a **five-workspace npm monorepo in a layered/hexagonal
arrangement**: a stateless edge worker with a KV repository layer and an embedded
search index plus A\* router, a framework-free TypeScript client with its own
repository, adapter, route, and guidance layers, and two pure V8-native data-plane
packages (search, routing) that both the worker and the client import — and a pure
build-time token/cartography package underneath.
```mermaid
graph TD
classDef client fill:#EB8008,stroke:#8A4C00,stroke-width:2px,color:#1A1A1A
classDef worker fill:#D9D2C6,stroke:#736B1E,stroke-width:2px,color:#1A1A1A
classDef store fill:#ED3F1C,stroke:#8C1F0A,stroke-width:2px,color:#FFFFFF
classDef build fill:#1A1A1A,stroke:#000000,stroke-width:2px,color:#FFF8F0
classDef plane fill:#736B1E,stroke:#4A440F,stroke-width:2px,color:#FFF8F0
A["Telegram client<br/>chat + Mini App webview"]:::client
B["@araharah/bot Worker<br/>src/webhook.ts"]:::worker
C["grammY handlers<br/>commands + /cari + fallbacks"]:::worker
D["Security<br/>initdata.ts HMAC"]:::worker
E[("KV chat_sessions<br/>session:* 900s · chat:*<br/>loc:* 300s · guidance:* 7200s")]:::store
R[("R2 ROAD_GRAPH<br/>kl-roadgraph.v1.bin")]:::store
Q["scheduled()<br/>*/2 turn-card cron"]:::worker
F["@araharah/web Mini App<br/>src/main.ts"]:::client
G["Session client + store<br/>src/session/"]:::client
H["Location chain<br/>src/location/"]:::client
I["MapLibre map + UI<br/>src/map/, src/ui/"]:::client
RT["Route controller + guidance<br/>src/route/, src/guidance/"]:::client
S["@araharah/search<br/>gazetteer SearchSource"]:::plane
U["@araharah/routing<br/>graph · snap · A*"]:::plane
J["@araharah/theme<br/>dist/rams.css + basemap"]:::build
A -->|"POST /telegram/webhook"| B
A -->|"loads Mini App"| B
B --> C
B --> D
B --> E
B --> R
Q --> E
Q --> R
C --> E
C --> S
C -->|"sendPhoto turn-cards"| A
F -->|"POST /api/session initData"| B
F -->|"GET /api/location Bearer"| B
F -->|"POST /api/location-fallback"| B
F -->|"POST /api/route Bearer"| B
F -->|"POST|GET|DELETE /api/guidance"| B
F -->|"build-time imports"| S
F -->|"build-time imports"| U
G --> F
H --> G
F --> H
H --> I
RT -->|"reroute"| B
J -->|"compiled CSS + inlined style JSON"| F
```
## Component Diagram
```mermaid
graph LR
classDef ws fill:#EB8008,stroke:#8A4C00,stroke-width:2px,color:#1A1A1A
classDef wsfill fill:#FFF8F0,stroke:#EB8008,stroke-width:2px,color:#1A1A1A
classDef botfill fill:#F5EDE0,stroke:#736B1E,stroke-width:2px,color:#1A1A1A
classDef plane fill:#736B1E,stroke:#4A440F,stroke-width:2px,color:#FFF8F0
classDef store fill:#ED3F1C,stroke:#8C1F0A,stroke-width:2px,color:#FFFFFF
classDef ext fill:#E0D9CD,stroke:#564335,stroke-width:2px,color:#1A1A1A
subgraph ROOT["root — npm workspaces"]
direction TB
subgraph THEME["theme · @araharah/theme"]
direction TB
T1["src/tokens.css<br/>@theme static, 55 colors"]:::wsfill
T2["src/components.css"]:::wsfill
T3["basemap/build.mjs"]:::wsfill
T4["dist/rams.css"]:::wsfill
T5["basemap/liberty-rams.json"]:::wsfill
T1 --> T4
T2 --> T4
T1 -->|"derives roles"| T3
T3 --> T5
end
subgraph WEB["web · @araharah/web"]
direction TB
W1["src/main.ts"]:::wsfill
W2["src/session/"]:::wsfill
W3["src/location/"]:::wsfill
W4["src/map/"]:::wsfill
W5["src/ui/ + src/telegram/"]:::wsfill
W6["src/search/ + src/route/<br/>+ src/guidance/"]:::wsfill
W7["dist/ (served)"]:::wsfill
W1 --> W2
W1 --> W3
W1 --> W4
W1 --> W5
W1 --> W6
W1 --> W7
W4 --> W7
W5 --> W7
W6 --> W7
end
subgraph BOT["bot · @araharah/bot"]
direction TB
B1["src/webhook.ts"]:::botfill
B2["src/security/initdata.ts"]:::botfill
B3["src/bot/ + src/guidance/cron.ts"]:::botfill
B4["src/kv/"]:::botfill
B5["src/routing/worker-router.ts"]:::botfill
B6["src/config.ts Env"]:::botfill
B1 --> B2
B1 --> B3
B1 --> B4
B1 --> B5
B1 --> B6
end
subgraph SEARCH["search · @araharah/search"]
direction TB
S1["src/gazetteer.ts<br/>lazy memoized index"]:::plane
S2["src/source.ts<br/>SearchSource #1"]:::plane
S3["src/score.ts + normalize.ts"]:::plane
S1 --> S2
S3 --> S2
end
subgraph ROUTING["routing · @araharah/routing"]
direction TB
R1["src/graph.ts<br/>CSR codec"]:::plane
R2["src/snap.ts + astar.ts"]:::plane
R3["src/fixture-source.ts<br/>#1 · http-source.ts<br/>engine-A client seam"]:::plane
R1 --> R2
R1 --> R3
R2 --> R3
end
KV[("KV chat_sessions")]:::store
R2B[("R2 ROAD_GRAPH<br/>araharah-graphs")]:::store
CRON["Cron Trigger<br/>*/2 * * * *"]:::ext
TG["Telegram Bot API"]:::ext
OFM["OpenFreeMap tiles"]:::ext
end
WEB -->|"workspace deps @araharah/{theme,search,routing}"| THEME
WEB --> SEARCH
WEB --> ROUTING
BOT -->|"deps @araharah/{search,routing}"| SEARCH
BOT --> ROUTING
BOT -->|"assets binding ../web/dist"| WEB
B1 --> KV
B4 --> KV
B5 --> R2B
B3 --> KV
CRON --> B1
B1 <-->|"webhookCallback / sendMessage"| TG
W3 <-->|"chat pin"| TG
W4 --> OFM
```
## Workspaces Dependency Graph
```mermaid
graph TD
classDef pkg fill:#EB8008,stroke:#8A4C00,stroke-width:2px,color:#1A1A1A
classDef dep fill:#736B1E,stroke:#4A440F,stroke-width:2px,color:#FFF8F0
classDef out fill:#D9D2C6,stroke:#736B1E,stroke-width:2px,color:#1A1A1A
ROOT["araharah (private root)"]:::out
TH["@araharah/theme"]:::pkg
SE["@araharah/search"]:::pkg
RO["@araharah/routing"]:::pkg
BT["@araharah/bot"]:::pkg
WB["@araharah/web"]:::pkg
KVS[("KV chat_sessions")]:::dep
R2S[("R2 ROAD_GRAPH")]:::dep
TGAPI["Telegram Bot API"]:::dep
ROOT -->|"workspaces"| TH
ROOT -->|"workspaces"| SE
ROOT -->|"workspaces"| RO
ROOT -->|"workspaces"| BT
ROOT -->|"workspaces"| WB
WB -->|"dependencies: @araharah/theme *"| TH
WB -->|"dependencies: @araharah/search *"| SE
WB -->|"dependencies: @araharah/routing *"| RO
BT -->|"dependencies: @araharah/search *"| SE
BT -->|"dependencies: @araharah/routing *"| RO
BT -->|"static assets ../web/dist"| WB
BT --> KVS
BT --> R2S
BT --> TGAPI
WB -->|"api routes"| BT
```
`@araharah/search` and `@araharah/routing` are the shared V8-native data planes: the
**bot Worker** imports them at runtime (gazetteer search, graph decode, snap, A\*),
the **web app** imports the same sources at build time (destination resolution, the
`RoutingSource` ladder, polyline decoding). Neither data plane reads the environment,
the network, or storage. `@araharah/theme` stays web-only (`"@araharah/theme": "*"`
in `web/package.json` — `dist/rams.css` in `web/src/shell.css`,
`basemap/liberty-rams.json` in `web/src/map/map.ts`). The bot has no workspace
dependency on theme or web; its coupling to web is a **build-order coupling only** —
the root `build` script builds theme then web, and the Worker's `[assets]` binding
points at `../web/dist`.
Both data-plane packages have **no `exports`/`main` field** — consumers import the
explicit subpath with extension, e.g. `@araharah/search/src/index.ts` and
`@araharah/routing/src/index.ts`. A bare package specifier resolves to nothing.
## Data Flow
### 1. Telegram command surface (bot → chat)
Telegram delivers an update to `POST /telegram/webhook`. The Worker first fails closed
if `WEBHOOK_SECRET` is unset, then hands the request to grammY's
`webhookCallback(bot, "cloudflare-mod", …)`, which constant-time-compares the
`X-Telegram-Bot-Api-Secret-Token` header. `createBot(env)` is instantiated **per
request** (the token only arrives with `env`), registers the command stack, and grammY
dispatches:
```
POST /telegram/webhook → webhookCallback (secret check) → createBot(env)
→ registerStart / registerHelp / registerPrivacy → pickLocale(language_code)
→ ctx.reply(plain text, link_previews disabled)
```
`createBot` also builds ONE memoized gazetteer `SearchSource` (backend #1) shared
between the `/cari` + `/search` command surface (`bot/src/bot/commands/cari.ts`) and
the alternates switcher (`bot/src/bot/handlers/search-alternate.ts`): `/cari` renders
a venue card whose OPEN button deep-links `<MINI_APP_URL>/?dest=<id>` (origin from the
configured `MINI_APP_URL` var, never user input), and the alternates handler swaps the
selected entry via a callback. The venue card's inline buttons and the
location-fallback reply keyboard are the two documented exceptions to the
plain-text-only command rule.
All user-facing copy comes from `bot/src/i18n/strings.ts`; `bot/scripts/register-commands.mjs`
derives the persistent `/setMyCommands` menu descriptions from the same `helpMenu` lines,
so menu and reply cannot drift.
### 2. Mini App boot (session handshake)
```mermaid
sequenceDiagram
participant WA as WebApp webview
participant MAIN as web/src/main.ts
participant SESS as session/client.ts
participant WK as Worker /api/session
participant ID as security/initdata.ts
participant KV as KV chat_sessions
MAIN->>MAIN: applySafeArea(resolveSafeInsets(wa))
MAIN->>MAIN: bootMap() mounts MapLibre FIRST
MAIN->>MAIN: register strip, cards, recenter
MAIN->>SESS: createSession(wa.initData) [verbatim string]
SESS->>WK: POST /api/session, body = initData (≤ 4 KB)
WK->>ID: validateInitData(raw, BOT_TOKEN)
ID-->>WK: ok | malformed | tampered | stale
WK->>KV: mintSession(chat_sessions, claim)
KV-->>WK: opaque UUID token
WK-->>SESS: 200 { token }
SESS->>SESS: setSession(token) [module scope only]
```
The HMAC path: `dataCheckString` = all initData fields **minus `hash` only**, sorted by
field name, joined with `\n`; secret key = `HMAC-SHA256(key="WebAppData",
message=botToken)`; check = `HMAC-SHA256(key=secretKey, message=dataCheckString)`
compared in constant time against `hash`. `signature` participates in the check.
`auth_date` older than 900 s is rejected as `stale`.
### 3. Location acquisition chain
```mermaid
flowchart TD
classDef s fill:#EB8008,stroke:#8A4C00,stroke-width:2px,color:#1A1A1A
classDef ok fill:#F5EDE0,stroke:#736B1E,stroke-width:2px,color:#1A1A1A
classDef no fill:#ED3F1C,stroke:#8C1F0A,stroke-width:2px,color:#FFFFFF
START["acquireFix(deps, lastAccepted)"]:::ok
L1{"telegramLocationSupported<br/>isVersionAtLeast 8.0"}:::ok
NUDGE{"looksStale(prev, fix)<br/>haversine < 0.01 m"}:::ok
L2{"webviewLocationSupported"}:::ok
L3{"getToken() !== null"}:::ok
LIVE["return { fix, state: live }"]:::s
DEN1["denied / timeout"]:::no
DEN2["denied / no-permission"]:::no
START --> L1
L1 -->|"yes"| NUDGE
NUDGE -->|"no"| LIVE
NUDGE -->|"yes: discarded webview read"| L1
L1 -->|"no"| L2
L2 -->|"yes + fix"| LIVE
L2 -->|"no fix"| L3
L3 -->|"yes: bounded 60s poll"| LIVE
L3 -->|"poll expired"| DEN1
L3 -->|"no token"| DEN2
```
Link 1 (`telegram.ts`) calls `LocationManager.init()` then `getLocation()`, with a hard
8 s wrapper timeout because the API documents no timeout. A byte-identical fix to the
last accepted one routes to the nudge: one **discarded** webview read refreshes the
Android native cache, then telegram is retried. Link 2 (`webview.ts`) calls
`navigator.geolocation.getCurrentPosition` with `maximumAge: 0`. Link 3
(`chat-pin.ts`) polls `GET /api/location` every 2 s for at most 60 s.
### 4. Chat-pin relay (the Desktop-Telegram path)
```mermaid
sequenceDiagram
participant U as User (chat)
participant B as Bot handler
participant KV as KV loc:chat_id
participant MA as Mini App
participant W as Worker
MA->>W: POST /api/location-fallback (Bearer token)
W->>W: getClaimByToken → claim.chat_id
W->>B: requestLocationFallback(bot, chat_id, locale)
B->>U: reply keyboard, request_location: true
U->>B: taps button, native location picker
B->>KV: writePin(kv, chat_id, { lat, lng, acc }) expirationTtl 300
loop every 2 s, bounded 60 s
MA->>W: GET /api/location (Bearer token)
W->>KV: getPin(chat_sessions, claim.chat_id)
KV-->>W: LocationPin | null
W-->>MA: { pin } (200) or { pin: null }
end
```
Both `/api/location` and `/api/location-fallback` derive the target `chat_id` from the
**resolved session claim**, never from client input, and derive locale from
`claim.language_code`. Telegram has no push channel from chat to webview, so this poll
is server-mediated by necessity.
### 5. Destination deep-link (search, both carriers)
The destination id is a **plain public gazetteer slug** — no crypto, no token anywhere
on this path. `web/src/search/resolve-destination.ts` reads it from TWO carriers in a
fixed order with ONE shared validation (`[A-Za-z0-9_-]{1,64}`, optional `dest_` wire
prefix):
1. **PRIMARY — the URL query string** (`?dest=<id>`): the `/cari` OPEN inline-button
path; Telegram opens the button's URL verbatim in the webview.
2. **SECONDARY — `start_param` inside the SIGNED initData string**: the documented
direct-link (`t.me/<bot>?startapp=<id>`) and home-screen-shortcut path, treated as
an untrusted exact-lookup key.
The id resolves against the webview's **own build-time copy of the shared gazetteer**
(`@araharah/search` inlined by Vite). A hit renders the destination card with a
START-route action; a miss renders the honest "destination unavailable" card and the
boot continues staged — never a blank map. The resolved entry lives in module scope
(memory only); nothing persists.
### 6. Route consult (engine A over R2)
```mermaid
sequenceDiagram
participant MAIN as main.ts (auto-consult, once per boot)
participant CTRL as route/route.ts
participant HTTP as http-source.ts (backend #2 seam)
participant W as Worker /api/route
participant G as loadGraph (R2 ROAD_GRAPH)
participant A as snapToGraph + routeAstar
MAIN->>CTRL: route { origin: fix, destinationId }
CTRL->>HTTP: POST endpoint (injected), bearer = memory token
HTTP->>W: { origin, destinationId } (≤ 4 KB)
W->>W: getClaimByToken — Bearer claim gate
W->>G: R2 GET kl-roadgraph.v1.bin (ONE per cold isolate, memoized)
G-->>W: RoadGraph | null
W->>A: bbox-check origin → snap origin → snap destination → A*
A-->>W: RouteResult | null
W-->>HTTP: 200 { result } | 200 JSON null
HTTP-->>CTRL: RouteResult | null (fail-honest)
```
`bot/src/routing/worker-router.ts` is engine A's whole server surface: the decoded
graph memoizes per isolate (`loadGraph` — the ONLY R2 loader, shared with the cron
arm), the origin is bbox-checked against `graph.bbox` before any snap/A\* work, and
**every miss class answers `200 null`** — unknown destination, snap miss, unreachable
pair, absent/corrupt graph — the honest router-pending state (never a 500, never a
fake route). Alternatives generation is prohibited on this path: exactly one best
route. The client composes the ladder at the import site —
`createFixtureRoutingSource` (backend #1, the 15-pair golden corpus) followed by
`createHttpRoutingSource` (engine A) — with the null contract end to end.
### 7. Guidance (mint → resume → cron push)
```mermaid
sequenceDiagram
participant MA as Mini App
participant W as Worker /api/guidance
participant KV as KV guidance:id (7200s)
participant C as scheduled() every 2 min
MA->>W: POST /api/guidance (Bearer) — six-field route snapshot
W->>W: getClaimByToken → chat_id minted from the CLAIM (body chat_id structurally ignored)
W->>KV: mintGuidanceSession — inline snapshot, expirationTtl 7200
KV-->>W: envelope (uuid id)
W-->>MA: 201 { id }
Note over MA: webview killed / phone locked
MA->>W: GET /api/guidance?id=<uuid> — resume carrier ?g=<uuid>
W->>KV: getGuidanceSession — foreign or absent are indistinguishable 404s
KV-->>MA: envelope → straight into the live trip
loop every 2 min (stateless — zero KV writes)
C->>KV: list prefix guidance: → getGuidanceSession per key
C->>C: projectTurnCards(envelope, nowSec, streetAt(loadGraph))
C->>MA: sendPhoto glyph + OPEN (?g=<id>) — due cards only, cadence 240 s
end
```
The envelope is the **frozen Phase 5 contract**: exactly four fields (`id`, `chat_id`,
`route_snapshot`, `created_at`); the snapshot is INLINE (one key, one TTL) with six
fields (`destinationId`, `destinationName`, `distanceM`, `durationS`, `geometry`
polyline6, `startedAtSec`). Every write carries `expirationTtl: 7200` on the same call
line — the PG1 TTL law covers this file. On the web side,
`web/src/guidance/guidance.ts` is the pure reroute state machine (`idle → on_route →
off_route → resuming/rerouting → arrived`, accuracy-aware threshold, 20 s storm guard)
and `web/src/guidance/watch.ts` is the throttled `watchPosition` stream (guidance work
runs on ≥ 10 m moved or ≥ 5 s elapsed — fixes are consumed in the caller's frame and
never transmitted).
### 8. Worker routing table
| Method | Path | Handler in `bot/src/webhook.ts` | Purpose |
| --- | --- | --- | --- |
| POST | `/telegram/webhook` | inline in `fetch()` | grammY webhook adapter + secret-token compare |
| POST | `/api/session` | `handleSession` | initData → one-time opaque session token |
| GET | `/api/location` | `handleLocation` | Bearer-authenticated chat-pin relay |
| POST | `/api/location-fallback` | `handleLocationFallback` | Ask the bot to send the reply keyboard |
| POST/GET/DELETE | `/api/guidance` | `handleGuidance` | Mint / resume / delete the 7200 s guidance envelope |
| POST | `/api/route` | `handleRoute` (in `src/routing/worker-router.ts`) | Bearer-gated engine-A routing over the R2 road graph |
| * | anything else | — | 404 |
`run_worker_first: ["/api/*", "/telegram/*"]` guarantees the Worker's `fetch` owns these
paths before the static asset fallback runs; every other path resolves to the `web/dist`
bundle. The same `fetch` handler also exports `scheduled()` — the `*/2` cron that runs
the turn-card push (flow 7).
## Build & Deploy Topology
```mermaid
graph LR
classDef step fill:#1A1A1A,stroke:#000000,stroke-width:2px,color:#FFF8F0
classDef art fill:#D9D2C6,stroke:#736B1E,stroke-width:2px,color:#1A1A1A
classDef cloud fill:#EB8008,stroke:#8A4C00,stroke-width:2px,color:#1A1A1A
classDef store fill:#ED3F1C,stroke:#8C1F0A,stroke-width:2px,color:#FFFFFF
T1["theme: tailwindcss -i src/theme.css -o dist/rams.css --minify"]:::step
T2["theme: basemap/build.mjs<br/>fetch liberty → derive roles → liberty-rams.json"]:::step
W1["web: vite build → dist/"]:::step
W2["web: vite build --mode harness → dist-harness/"]:::step
B1["bot: wrangler deploy"]:::step
B2["bot: scripts/set-webhook.mjs"]:::step
RG["routing: build-road-graph.mjs<br/>→ kl-roadgraph.v1.bin"]:::step
RU["routing: upload-road-graph.mjs<br/>→ R2 (fork contract)"]:::step
AR1["theme/dist/rams.css + fonts"]:::art
AR2["web/dist (index.html, hashed assets,<br/>inlined liberty-rams style, public/glyphs)"]:::art
AR3["web/dist-harness (never deployed)"]:::art
EDGE["Cloudflare Worker araharah-bot<br/>[assets] ../web/dist + cron */2"]:::cloud
KVS[("KV chat_sessions")]:::store
R2B[("R2 ROAD_GRAPH")]:::store
T1 --> W1
T2 --> W1
T1 --> AR1
W1 --> AR2
W2 --> AR3
AR2 --> EDGE
B1 --> EDGE
B1 --> B2
RG --> RU
RU --> R2B
EDGE --> KVS
EDGE --> R2B
```
The root scripts encode the ordering: `npm run build` = `build -w theme && build -w web`;
`npm run deploy` = `npm run build && npm run deploy -w bot` (which is
`wrangler deploy && node scripts/set-webhook.mjs`). `bot/wrangler.jsonc` binds
`assets.directory` to `../web/dist`, so `web/dist` must exist before a bot deploy.
The road-graph binary is a separate provisioning track (never a build prerequisite):
`routing/scripts/build-road-graph.mjs` writes `routing/data/kl-roadgraph.v1.bin`
(~15 MB, a build artifact that is NOT committed) and
`routing/scripts/upload-road-graph.mjs` puts it on R2 — the fork contract is
`docs/ROAD-GRAPH.md`.
The two web builds never share an outDir: `vite.config.ts` sets
`outDir: "dist-harness"` only under `--mode harness` and defines
`__ARAHARAH_HARNESS__` to `true` there and `false` everywhere else, so the mock
installer and the `fetch` stubs in `web/index.html` are dead-code-eliminated from
production. `web/test/build-artifact.test.mjs` (BA1–BA10) proves the shipped bundle
references no third-party CDN, self-hosts the woff2 fonts, contains the inlined Rams
style, and carries neither `installMockWebApp` nor `__ARAHARAH_MOCK__`. The turn-card
glyphs (`web/public/glyphs/*.png`, rendered by `routing/scripts/build-glyphs.mjs`) are
static assets the cron references as `${MINI_APP_URL}/glyphs/<glyph>.png`.
<!-- VERIFY: production Worker hostname and KV namespace id come from bot/wrangler.jsonc and bot/.dev.vars, both environment-specific. -->
## Key Abstractions
### `@araharah/bot`
| Abstraction | File | Description |
| --- | --- | --- |
| `Env` | `bot/src/config.ts` | Typed contract: `BOT_TOKEN`, `WEBHOOK_SECRET` (non-empty — the webhook fails closed without it), `chat_sessions: KVNamespace`, `ROAD_GRAPH: R2Bucket` (first R2 binding), `MINI_APP_URL` (public deep-link origin). |
| `createExportedHandler(opts)` | `bot/src/webhook.ts` | The `ExportedHandler<Env>` factory — six fetch routes plus `scheduled()`; 404 otherwise. Production passes nothing; offline tests inject a recording `baseFetch`. |
| `validateInitData(raw, botToken, opts)` | `bot/src/security/initdata.ts` | Pure HMAC-SHA256 validation of Telegram WebApp initData. Depends only on `crypto.subtle`, so it runs unchanged in Workers and bare Node. Returns `InitDataResult`. |
| `SessionClaim` / `mintSession` / `getClaimByToken` / `getTokenForChat` | `bot/src/kv/sessions.ts` | TTL-only session layer. `SESSION_TTL_SECONDS = 900`. Key scheme `session:<token>` → claim, `chat:<chat_id>` → current token. Rotate-on-new with a best-effort reconciliation sweep. |
| `LocationPin` / `writePin` / `getPin` / `clearPin` | `bot/src/kv/location-pin.ts` | TTL-only pin layer. `PIN_TTL_SECONDS = 300`. Key `loc:<chat_id>`. `getPin` range-validates on read. |
| `GuidanceSessionEnvelope` / `GuidanceRouteSnapshot` / `mint/get/deleteGuidanceSession` / `GUIDANCE_KEY_PREFIX` | `bot/src/kv/guidance-sessions.ts` | The frozen four-field envelope (six-field inline snapshot), `GUIDANCE_SESSION_TTL_SECONDS = 7200`, key `guidance:<uuid>`. Defensive reads (garbage → null); ids UUID-guarded before any KV touch. |
| `handleRoute` / `loadGraph` | `bot/src/routing/worker-router.ts` | Engine A's server surface: Bearer claim gate, 4 KB body cap, bbox pre-check, snap → A\* over the R2-hosted graph, `200 null` for every miss. `loadGraph` is the ONLY R2 loader — one memoized GET per cold isolate, shared with the cron arm. |
| `projectTurnCards` / `makeStreetAt` | `bot/src/guidance/cron.ts` | The PURE projection over the frozen snapshot — when a card is due (`nowSec % CARD_CADENCE_S < SCAN_TICK_S`), glyph + caption composition, street reconstruction from the graph. `CARD_CADENCE_S = 240`; the cron is stateless (zero KV writes). |
| `createBot(env, opts)` | `bot/src/bot/bot.ts` | grammY factory; wires `/start` + `/mula`, `/help`, `/privacy` + `/privasi`, `/cari` + `/search`, the alternates switcher, and the location-fallback handler. Builds one shared gazetteer `SearchSource`. |
| `registerCari(bot, source, origin)` | `bot/src/bot/commands/cari.ts` | The search command surface: venue card with OPEN deep link (`?dest=<id>`) and alternates callbacks — the second documented exception to the text-only rule. |
| `requestLocationFallback(bot, chatId, locale)` | `bot/src/bot/handlers/location-fallback.ts` | Sends the `request_location` reply keyboard. Single caller: `handleLocationFallback`. |
| `strings` / `pickLocale` | `bot/src/i18n/strings.ts` | The bot's entire copy surface (`en`, `ms`). `pickLocale` maps an `ms`/`ms-*` language code to Malay, everything else to English. |
| `KvLike` | `bot/src/kv/*.ts` | Structural KV interface (module-private, not exported — the derived `MintSessionKv`/`LocationPinKv`/`GuidanceSessionKv` are the public surface) — lets tests pass a fake without depending on Workers types. |
### `@araharah/search`
| Abstraction | File | Description |
| --- | --- | --- |
| `SearchSource` | `search/src/types.ts` | The pluggable search seam: async `search(q)` + synchronous `byId(id)`. The documented ladder is backend #1 (gazetteer, shipped) with later rungs slotted at the import site — no env read, no registry. |
| `createGazetteerSource()` | `search/src/source.ts` | Backend #1. The refusal contract is load-bearing: an EMPTY array means "not in the v1 area" — never a fabricated pin. Ranking is score-band DESC then entry-id ASC (deterministic tie-break). |
| `search(text, limit)` | `search/src/source.ts` | The synchronous scoring path — shared by the bot, the Mini App, and the corpus runner: the measured quality IS the shipped quality. |
| `getEntries` / `getEntryById` / `getIndex` | `search/src/gazetteer.ts` | The lazy, memoized index over a static JSON import (`data/kl-gazetteer.json`, ODbL notice inside). Three laws: lazy + memoized (never module-scope work), survives three environments (Worker bundle, Vite bundle, bare Node), no logging/storage. |
| `scoreCandidate` / `TIER` / `FUZZY_ADMIT_SIMILARITY` | `search/src/score.ts` | Tiered admission — EXACT / ALIAS_EXACT / PREFIX / TOKEN_SET bands always admitted; FUZZY only at similarity ≥ 0.72. `normalize` (with `rules/abbreviations.ts`) canonicalizes both sides of every comparison. |
| `GazetteerEntry` | `search/src/types.ts` | `id` (slug), `name`, `aliases`, coordinates, category — the entry shape every carrier (bot card, webview resolver, guidance snapshot) reduces to. |
### `@araharah/routing`
| Abstraction | File | Description |
| --- | --- | --- |
| `RoutingSource` | `routing/src/types.ts` | The pluggable routing seam: `route(req) → RouteResult \| null`. Backend ladder: #1 `"fixture"` → #2 `"engine-a"` (LIVE, via `/api/route`) → engine B (client-side webview router, documented post-v1). Selection happens at the import site in code — no env read, no registry. |
| `RouteResult` / `RouteOption` / `RouteStep` / `RouteGeometry` | `routing/src/types.ts` | Day-one contract: options 1..3 (index 0 = best), `cumulativeDistanceM` parallel to points (the guidance projection contract), polyline6 `geometry`, optional `kind` maneuver tags. Fixture-world steps are empty arrays — honest "not wired". |
| `RoadGraph` / `encodeGraph` / `decodeGraph` | `routing/src/graph.ts` | The CSR road-graph binary codec (`GRAPH_MAGIC`, `GRAPH_VERSION`) — the on-disk form of `kl-roadgraph.v1.bin`. V8-native, dependency-free, no network. |
| `snapToGraph` / `SnapResult` | `routing/src/snap.ts` | Snaps a raw fix onto the graph (bbox-margined); the origin of every route computation and of the cron's street reconstruction. |
| `routeAstar` / `deriveSteps` / `classifyTurn` | `routing/src/astar.ts` | The A\* engine plus the maneuver classifier (`depart`/`left`/…/`arrive`) that feeds both the webview guidance machine and the cron's turn cards. |
| `createFixtureRoutingSource` | `routing/src/fixture-source.ts` | Backend #1: the committed 15-pair golden KL corpus (`data/route-fixtures.json` + `test/fixtures/golden-pairs.json`). Demo-grade by design; the tolerance gate is `npm run routing:corpus`. |
| `createHttpRoutingSource(options)` | `routing/src/http-source.ts` | Engine A's client seam: injected `endpoint` + `bearer` getter, defensively validates every response field, resolves null on any failure (the null contract crossing HTTP). Never holds or persists the token. |
| `decodePolyline6` / `encodePolyline6` / `haversineM` / `deriveCumulativeDistanceM` | `routing/src/polyline.ts`, `routing/src/geo.ts` | The storage geometry format and the ONE distance authority — every distance in the system flows through `geo.ts`. |
### `@araharah/web`
| Abstraction | File | Description |
| --- | --- | --- |
| `Fix` | `web/src/location/types.ts` | The single normalized position shape every source reduces to: `lat`, `lng`, `accuracyM`, `headingDeg`, `ts`, `source`. Freshness lives in the fix. |
| `LocationState` | `web/src/location/types.ts` | Discriminated union: `idle` / `validating` / `acquiring` / `awaiting-pin` / `live` / `stale` / `denied`. |
| `acquireFix(deps, lastAccepted)` | `web/src/location/chain.ts` | The three-link state machine. Pluggable cadence — no `setInterval`, no `watchPosition`. |
| `isFixStale(fix, nowMs)` / `fixAgeSeconds` | `web/src/location/chain.ts` | The 45 s signal-lost threshold and the `FIX Ns AGO` telemetry helper. |
| `readDestinationId` / `resolveDestination` / `setDestination` | `web/src/search/resolve-destination.ts` | The two-carrier deep-link resolver (`?dest=` URL, then signed initData's `start_param`), one shared slug validation, memory-only result, honest-null on miss. |
| `buildDestinationCard` / `buildDestinationUnavailableCard` | `web/src/search/destination-card.ts` | The search surface's Rams cards (destination confirmed / honest unavailable). |
| `createRouteController` | `web/src/route/route.ts` | The consult/select/walk-back state machine and the guidance pump: auto-consults once per boot, draws the selected line (`setData` on the `route` source), starts the guidance watch, delegates every guidance decision. Touches no DOM/map directly — everything injected. |
| `installRouteLayers(m)` | `web/src/route/route-layers.ts` | Installs the `route` / `route-alt` map sources inside map.ts's ONE load handler; a drawn route is static furniture (only selection changes it). |
| `createGuidanceSession` / `stepGuidance` | `web/src/guidance/guidance.ts` | The pure reroute state machine: `idle → on_route → off_route → resuming/rerouting`, accuracy-aware threshold (`max(35, 2×accuracy)` capped 100), 3-fix + 8 s grace, 20 s storm guard. A pure reducer — replay-verifiable in Node. |
| `createGuidanceWatch(geo, onFix)` | `web/src/guidance/watch.ts` | The gated position stream: `watchPosition` high-accuracy, throttled to ≥ 10 m moved or ≥ 5 s elapsed. Fixes are consumed in the caller's frame — never stored, logged, or transmitted. |
| `TelegramWebAppLike` | `web/src/telegram/webapp.ts` | Hand-rolled ~20-member contract for `window.Telegram.WebApp`. `getWebApp()` may honestly return `null` off-Telegram. |
| `createSession(initData)` | `web/src/session/client.ts` | POSTs the verbatim initData string; returns `{ ok: true, token }` or a `malformed`/`tampered`/`stale`/`network` reason. |
| `setSession` / `getSession` / `isSessionStale` / `clearSession` | `web/src/session/store.ts` | Module-scope token holder. No persistence call anywhere in the file. |
| `bootMap(container)` | `web/src/map/map.ts` | MapLibre v6 bootstrap with the build-time-inlined Rams style. Returns `null` on `GPUInitializationError`. |
| `createPositionMarker()` | `web/src/map/marker.ts` | SVG accuracy circle + 20 px dot + heading cone. Cone hidden entirely when heading is `null`. |
| `createFollowController(map, onModeChange)` | `web/src/map/follow.ts` | following/free camera. Only a user gesture (`movestart` with `originalEvent`) flips to free. |
| `createStatusStrip(host)` | `web/src/ui/strip.ts` | The status band — state lamp + state word on the left, telemetry on the right. |
| `buildBootCard` / `buildDeniedCard` / `buildSignalLostCard` | `web/src/ui/cards.ts` | Rams card surfaces composed from theme component classes; zero inline copy. |
| `createRecenterControl(host, locale, onRecenter)` | `web/src/ui/recenter.ts` | 40×40 follow plate with three instant notch states. |
| `watchTheme(wa)` / `resolveBranch` | `web/src/telegram/theme.ts` | The theme-slot machine: Telegram light/dark (or `header_bg_color` luminance fallback) selects between two Rams token pairs — `applyTheme`'s ONLY statement is the `data-tg-theme` attribute set; MapLibre is deliberately absent (the map style is never re-set). Pinned by `web/test/theme.test.mjs`. |
| `resolveSafeInsets` / `applySafeArea` | `web/src/telegram/safe-area.ts` | Union of CSS `env()` and `WebApp.safeAreaInset`, max per edge, emitted as CSS custom properties. |
| `wireBackButton(wa, hasOverlay)` | `web/src/telegram/backbutton.ts` | Total function — the context-aware resolver (guidance → confirm; confirm → CONTINUE; ended → search-root walk; route → Phase 5 walk; root → close) and the wire's per-state `sync()`. |
| `wireHomeScreen(wa, locale)` | `web/src/telegram/homescreen.ts` | The ONBD-03 home-screen affordance behind the Bot API 8.0 capability guard — Telegram-native methods only. |
| `installMockWebApp(options)` | `web/src/mock/telegram-mock.ts` | Harness-only fake `window.Telegram.WebApp` with a dev-signed initData. Excluded from `web/dist`. |
| `localeFromInitData(raw)` | `web/src/i18n/strings.ts` | Parses the locale out of the raw initData string — the session response carries only `{ token }`. |
### `@araharah/theme`
| Abstraction | File | Description |
| --- | --- | --- |
| `@theme static` block | `theme/src/tokens.css` | 55 `--color-*` tokens plus spacing and typography. `static` forces every token into the compiled CSS so the package is consumable as plain custom properties. |
| `theme/src/theme.css` | `theme/src/theme.css` | Entry stylesheet — `@import`s tailwindcss, tokens, components; declares seven self-hosted `@font-face` rules; defines the `--shell-bg` / `--shell-on-surface` theme slots (the two Rams token pairs the slot switch flips). |
| Component classes | `theme/src/components.css` | `.card`, `.btn-primary`, `.btn-secondary`, `.chip-*`, `.maneuver-*`, `.text-*`, `.toggle*`, `.input-field`, `.checkbox`. |
| `build.mjs` | `theme/basemap/build.mjs` | Forks OpenFreeMap `liberty`, deriving every cartographic color from `tokens.css` via `desaturate(tokenHex, satMultiplier, lightnessPerRole)` with saturation clamped to `[0.06, 0.12]`. |
| `liberty-rams.json` | `theme/basemap/liberty-rams.json` | The generated style. Accent guard fails the build if `#EB8008` / `#ED3F1C` / `#736B1E` leak into any layer. `building-3d` is removed (faux 3D). |
## Directory Structure Rationale
```
araharah/
├── package.json # private root; workspaces + script passthroughs
├── bot/ # @araharah/bot — the Cloudflare Worker
│ ├── src/
│ │ ├── webhook.ts # the single fetch handler; six routes + scheduled()
│ │ ├── config.ts # Env contract (KV, R2, MINI_APP_URL)
│ │ ├── bot/ # grammY factory, commands/, handlers/ (incl. /cari)
│ │ ├── security/ # initdata.ts — pure, isolation-free crypto
│ │ ├── kv/ # sessions.ts, location-pin.ts, guidance-sessions.ts — TTL-only repos
│ │ ├── routing/ # worker-router.ts — engine A's server surface
│ │ ├── guidance/ # cron.ts — the pure turn-card projection
│ │ └── i18n/strings.ts # every user-facing bot string
│ ├── scripts/ # set-webhook, register-commands, set-menu-button
│ ├── test/ # hand-rolled zero-dependency runner + suites
│ └── wrangler.jsonc # worker name, assets binding, run_worker_first, R2, cron
├── web/ # @araharah/web — the Mini App
│ ├── src/
│ │ ├── main.ts # boot choreography (order is load-bearing)
│ │ ├── session/ # client.ts (network), store.ts (memory)
│ │ ├── location/ # chain.ts + telegram/webview/chat-pin sources
│ │ ├── search/ # resolve-destination.ts, destination-card.ts
│ │ ├── route/ # route.ts controller, card, markers, layers
│ │ ├── guidance/ # guidance.ts machine, watch.ts, banner, eta, camera, haptics
│ │ ├── telegram/ # webapp, theme, backbutton, safe-area, homescreen
│ │ ├── map/ # map.ts, marker.ts, follow.ts
│ │ ├── ui/ # strip.ts, cards.ts, recenter.ts
│ │ ├── mock/ # harness-only Telegram fake
│ │ ├── i18n/strings.ts # every user-facing shell string
│ │ └── shell.css # Rams chrome; imports @araharah/theme/dist/rams.css
│ ├── public/glyphs/ # turn-card PNGs the cron deep-links
│ ├── test/ # runner + gate suites (copy, storage, artifact, chain, guidance)
│ ├── scripts/capture.mjs # screenshot capture
│ └── vite.config.ts # dual-build dist/ vs dist-harness/
├── search/ # @araharah/search — the gazetteer data plane
│ ├── src/ # gazetteer.ts, source.ts, score.ts, normalize.ts, types.ts
│ ├── src/data/ # kl-gazetteer.json (committed, ODbL notice inside)
│ ├── scripts/ # gazetteer-corpus.mjs, json-to-sqlite.mjs, overpass-topup.mjs
│ └── test/ # runner + suites + golden corpus fixtures
├── routing/ # @araharah/routing — the routing data plane
│ ├── src/ # graph.ts, snap.ts, astar.ts, polyline.ts, geo.ts,
│ │ # fixture-source.ts, http-source.ts, types.ts, index.ts
│ ├── data/ # kl-roadgraph.v1.bin — BUILD ARTIFACT, not committed
│ ├── scripts/ # build-road-graph.mjs, upload-road-graph.mjs,
│ │ # build-glyphs.mjs, route-corpus.mjs, fetch-route-fixtures.mjs
│ └── test/ # runner + suites + mini-graph fixtures
├── theme/ # @araharah/theme — design tokens + cartography
│ ├── src/ # tokens.css, components.css, theme.css, sheet.html
│ ├── fonts/ # self-hosted Geist + Space Grotesk woff2
│ ├── basemap/ # build.mjs + generated liberty-rams.json
│ ├── scripts/ # audit.mjs (token audit), screenshot.mjs
│ └── dist/rams.css # compiled output
├── scripts/ # root audits — privacy-audit.mjs, budget-audit.mjs,
│ │ # lib/ (source-gates, bundle-gates, budget-gates,
│ │ # cloudflare-probes), test/ (their suites)
├── stitch_minimalist_route_navigator/ # read-only design reference (mockups)
└── docs/
```
Rationale for the boundaries:
- **`bot/src/webhook.ts` owns routing; nothing else dispatches.** The Worker is the only
place that decides which path runs, so `run_worker_first`, the secret-token gate,
the body caps, and the cron tick all apply uniformly.
- **`bot/src/security/`, `bot/src/kv/`, and the two data-plane packages are
dependency-light on purpose.** `initdata.ts` imports nothing from grammY and runs in
both Workers and bare Node; `kv/*.ts` take a structural `KvLike` so the suites pass
fakes; `@araharah/search` and `@araharah/routing` are V8-native with zero runtime
dependencies, no env reads, and no network — the same sources run inside the Worker
bundle, the Vite bundle, and the Node corpus runners.
- **The backend ladders are constructed in code, not configured.** Search and routing
each expose a seam (`SearchSource`, `RoutingSource`) whose backend selection happens
at the import site — no env read, no config read, no dynamic registry. Swapping a
backend is an import-site change.
- **`bot/src/i18n/strings.ts` is the single copy source.** The commands register
script derives its menu text from the same `helpMenu` lines, and `bot/test/commands.test.mjs`
gates that the `/privacy` copy still names the four never-stored facts (location, routes,
trip history, profile) and keeps the ~15-minute TTL wording, in both locales.
- **`web/src/location/` separates sources from the chain.** Each source is a small module
that normalizes to `Fix` and answers `null` for denial; `chain.ts` owns ordering and
knows nothing about any single source's API.
- **`web/src/route/` + `web/src/guidance/` split wiring from decisions.**
`route.ts` only wires (consult once, draw, start the pump); `guidance.ts` is the pure
state machine that decides every guidance behavior; `watch.ts` owns the throttle; the
view modules (banner, eta, camera, haptics) each own one concern. Node-testable with
injected DOM/map/geo.
- **`web/src/session/` splits network from state.** `client.ts` owns fetch and error
vocabulary; `store.ts` owns the in-memory token and the 900 s staleness rule. Neither
ever touches storage.
- **`web/src/telegram/` is the only place that touches `Telegram.WebApp`.** Every other
module takes the narrow `TelegramWebAppLike` interface, which is what lets the mock,
the test fakes, and real clients satisfy the same contract.
- **`theme/` is pure build-time.** It exports no runtime code — a CSS bundle and a JSON
style. That is why `web` can depend on it without pulling a bundler into the token
package.
- **`scripts/` is the audit layer, not product code.** `privacy-audit.mjs` and
`budget-audit.mjs` are maintainer-time gates that import from `scripts/lib/`; nothing
in `bot/src`, `web/src`, `routing/src`, or `search/src` imports them.
- **`routing/data/` is a build artifact by law.** The 15 MB graph binary is gitignored;
forks build and upload their own (`docs/ROAD-GRAPH.md` is the fork contract).
- **`stitch_minimalist_route_navigator/` is `@source`-scanned, never edited.** The theme
compiles every class the four mockups use so the design vocabulary stays provable
against the shipped CSS.
## Cross-Cutting Concerns
### Privacy by construction — the gate lineage
Two layers of gates pin this rather than documenting it:
**The consolidated maintainer audit** — `npm run privacy-audit`
(`scripts/privacy-audit.mjs`), five static gates in `scripts/lib/source-gates.mjs` and
`scripts/lib/bundle-gates.mjs`, plus `--live` probes in
`scripts/lib/cloudflare-probes.mjs`:
| Gate id | Rule |
| --- | --- |
| `storage-api` | The memory-only law, repo-wide (the no-storage suite generalized). |
| `kv-ttl` | The PG1 same-line TTL law, repo-wide — every `.put(` line in `bot/src` carries `expirationTtl` as a named constant. |
| `log-vocabulary` | The D-14 category-only log law — no coordinate, payload, user id, or timestamp in any log line. |
| `runtime-hosts` | The POSITIVE fetch-host allowlist — no fetch to any host outside it. |
| `bundle-telemetry` | The PRODUCTION `web/dist` scan — no analytics, no beacon, no telemetry in the shipped artifact. |
| `kv-expiry` / `asset-headers` (with `--live`) | Live expiry metadata and cache-header verification — a skipped probe is never mistaken for a pass (exit 2 = NOT RUN). |
Exit codes: `0` clean, `1` violations, `2` NOT RUN (environment drift) — no silent
passes. The audit doubles as the fork onboarding check. `docs/PRIVACY-AUDIT.md` is the
human record.
**The in-suite gates** (the same laws asserted where the code lives):
- `bot/test/privacy.test.mjs` — the PG1/PG2 lineage: every `put` on `chat_sessions`
(now spanning `sessions.ts`, `location-pin.ts`, `guidance-sessions.ts`, and
`webhook.ts`) must carry an explicit `expirationTtl` on the same call line, as a
named `SCREAMING_SNAKE` constant. No write path can persist without a TTL.
- `web/test/no-storage.test.mjs` — comments stripped, every `web/src` file scanned; zero
occurrences of `localStorage`, `sessionStorage`, `indexedDB`, `caches`,
`document.cookie`, `WebApp.DeviceStorage`, `WebApp.SecureStorage`, `WebApp.CloudStorage`,
or any service-worker registration.
- `bot/test/route-privacy.test.mjs` — the route handler's structural proof: the only
persisted surface is the guidance envelope; the route request's origin never touches
storage or a log line.
The persisted surface is exactly `SessionClaim` (user_id, chat_id, issued_at,
language_code) for 900 s, `LocationPin` (latitude, longitude, horizontal_accuracy)
for 300 s, and the `GuidanceSessionEnvelope` for 7200 s — all in ONE KV namespace.
On the web side, guidance on-route fixes never leave the device; the ONLY
network-bound position is a reroute's origin (one point, on a real excursion, snapped
server-side). The client holds the token, the destination, and the last fix in module
scope only.
### TTL-only storage law
Every KV write carries an explicit TTL; nothing survives past it:
| Key family | TTL | Constant |
| --- | --- | --- |
| `session:<token>` / `chat:<chat_id>` | 900 s (~15 min) | `SESSION_TTL_SECONDS` |
| `loc:<chat_id>` | 300 s | `PIN_TTL_SECONDS` |
| `guidance:<uuid>` | 7200 s (~2 h) | `GUIDANCE_SESSION_TTL_SECONDS` |
The R2 bucket (`ROAD_GRAPH`) is a read-only binary host, not a persistence surface.
Exactly ONE `kv_namespaces` entry is the law (`GE5`) — a new KV family is a design
change, not a refactor.
### Access control
Every chat-scoped route (`/api/location`, `/api/location-fallback`, `/api/guidance`)
resolves the `chat_id` from the Bearer session claim, never from client input; locale
comes from `claim.language_code`. The location-fallback request body is never read; a
guidance POST's `chat_id` field is structurally dropped (`validateSnapshot` builds the
new object field-by-field); GET/DELETE compare `envelope.chat_id === claim.chat_id` so
foreign and absent are indistinguishable 404s (no existence oracle for cross-chat
probing). `/api/route` uses the claim purely as an identity gate — routing itself is
chat-agnostic and nothing about the caller is stored, logged, or echoed.
### Honest degradation
Every degradation is a labeled state, never a silent one or a fabricated value:
`bootMap()` returning `null` renders the shell over `--color-surface-dim` with
`data-map="absent"`; a null heading hides the marker cone rather than guessing an
orientation; `getPin` range-validates so a corrupt record cannot fly the camera to null
island; the poll is bounded at 60 s so `AWAITING PIN` cannot persist; every source
timeout resolves `null` so the chain always advances; **the route null contract** —
every `/api/route` miss class answers `200 null` and the client renders the pending
card; a null road graph degrades the cron card to the bare-verb street-less
composition — the send still fires; guidance deviation becomes a labeled off-route
state with a storm-guarded reroute, never a silent camera jump.
### Category-only logging
The Worker's only log statements are categories — `[webhook] rejected: misconfigured`,
`[initdata] rejected: malformed|tampered|stale`, `[location]`/`[location-fallback]`/
`[guidance]`/`[route] rejected: unauthorized|malformed`,
`[guidance-cron] skipped: unconfigured|malformed`, `[guidance-cron] send failed`,
`[location-fallback] failed: telegram-send`. Never raw payloads, never user ids,
never chat ids, never coordinates, never timestamps. Rejection responses carry the
category only. The web app's console lines are state tokens (`ARAHARAH-ROUTE`,
`ARAHARAH-GUIDE`, `RAMS-BASEMAP-READY`, `ARAHARAH-LIVE`) — never coordinates. The
audits (`log-vocabulary` gate) prove this mechanically.
### The budget law
`npm run budget-audit` (`scripts/budget-audit.mjs`) measures the free-tier posture
instead of assuming it: route CPU (the A\* ladder, child-process), bundle size, KV
write classes (the cron's ZERO-writes law), subrequest classes, and the golden
corpora; `--live` adds observed cache headers. The record is `docs/BUDGETS.md`. The
cron's cadence is tuned to it: `*/2 * * * *` × one KV list-op per tick = 720
list-ops/day, inside the free plan's 1,000/day KV-list ceiling, and each tick performs
zero KV writes (stateless projection over the 7200 s envelopes).
### Testing
Five workspaces, one test idiom: `bot/test`, `web/test`, `search/test`, and
`routing/test` each use the same hand-rolled zero-dependency runner (`runner.mjs` +
`harness.mjs`), auto-discovering `*.test.mjs`; `theme` runs `scripts/audit.test.mjs`
in the same shape, and `scripts/test/` holds the audit tools' own suites. Gates are
grep/introspection assertions against source and build artifacts, not just behavioral
assertions. The corpus suites (`search/test/corpus.test.mjs`,
`routing/test/corpus.test.mjs`) assert the same measurements the standalone corpus
runners print — measured quality and shipped quality cannot drift.
## What Is Not Built Yet
The following are documented targets, **not** in the codebase: **engine B** (the fully
client-side webview router — graph on the phone; engine A's documented post-v1
evolution; the Valhalla-to-JS port was ruled infeasible and geojson-path-finder was
rejected — recorded so neither is re-proposed), Valhalla-on-VPS (the documented
provision path, not shipped), Neon/Postgres, RabbitMQ, Prometheus/Grafana, Resend,
opt-in telemetry (v1 carries zero telemetry — there is nothing to opt into yet), and
the Maptiler tile provider (the basemap today is the OpenFreeMap `liberty` fork).
Alternatives generation on `/api/route` is prohibited by design, not deferred.
Search backend #1 (gazetteer) and routing backends #1/#2 are the only ladder rungs
implemented; later rungs slot in at the import site without a client rewrite.