# arah² — Development Guide Contributor-facing reference for working on arah² day to day: local setup, the per-workspace dev loop, the build commands, the code style the project actually enforces, branch and commit conventions, and the project-specific gates you must not break. For *using* the app, see the [README](../README.md). For *how the system is put together*, see [ARCHITECTURE.md](./ARCHITECTURE.md). For *what to configure* (secrets, `wrangler.jsonc`, env vars), see [CONFIGURATION.md](./CONFIGURATION.md). For the road-graph fork contract, see [ROAD-GRAPH.md](./ROAD-GRAPH.md). For the free-tier posture, see [BUDGETS.md](./BUDGETS.md). --- ## Local setup ### Prerequisites | Requirement | Notes | | --- | --- | | Node.js + npm | No `engines` field and no `.nvmrc`/`.node-version` exist in the repo. The README says to use a current Node.js LTS. The `@araharah/search` and `@araharah/routing` sources are consumed by plain Node (the corpus runners rely on Node's type-stripping for relative `.ts` imports — the source headers call out Node 24). | | npm with workspace support | npm 7+ required for `workspaces`. The repo uses npm exclusively — `package-lock.json` is committed and there is no `pnpm-lock.yaml` or `yarn.lock`. | | A Cloudflare account | `wrangler` authenticates on first use via an interactive browser login. Needed for `bot:dev`, `bot:deploy`, KV access, and the R2 bucket (road graph). | | A Telegram bot token | From @BotFather. Required by `bot:dev` and by every script in `bot/scripts/`. | | Python 3 | Only for `theme`'s `gallery` / `basemap:preview`, which serve on port `8123` via `python3 -m http.server 8123`. | | Network access | Fonts, tiles, the Play CDN in the mockup reference pages, and the Overpass fetch inside `build-road-graph.mjs` are all fetched over the network; the capture harnesses need it. | ### Clone and install ```bash git clone http://gitmirah:3000/c-fu/arah2.git cd arah2 npm install ``` `npm install` at the root installs all five workspaces (`theme`, `bot`, `web`, `search`, `routing`) at once — npm hoists shared deps into the root `node_modules/`. There is no per-workspace `npm install` step. ### Configure local secrets There is no committed `.env`. The Worker reads secrets from `bot/.dev.vars` (gitignored via `bot/.gitignore`): ```bash cp bot/.dev.vars.example bot/.dev.vars # then edit bot/.dev.vars and fill in BOT_TOKEN, WEBHOOK_SECRET, WEBHOOK_URL, MINI_APP_URL ``` `MINI_APP_URL` is the public Mini App origin (not a secret) the `/cari` venue card's OPEN deep link is built from; it must match the URL registered in @BotFather. Full details, including the `process.env` vs `.dev.vars` precedence rule, are in [CONFIGURATION.md](./CONFIGURATION.md#environment-variables). ### Fork provisioning (before first run against real infra) A fresh fork needs its own road graph and its own bindings before `bot:dev` / `bot:deploy` route for real: 1. **Create the R2 bucket** (road-graph host — public configuration, never a secret): `npx wrangler r2 bucket create araharah-graphs` (or your own name — it must agree with `bot/wrangler.jsonc`'s `ROAD_GRAPH` `bucket_name`). 2. **Build the graph**: `node routing/scripts/build-road-graph.mjs` — fetches one Overpass extract and encodes `routing/data/kl-roadgraph.v1.bin` (build artifact, gitignored). `--check ` validates it offline. **Do not commit the binary.** 3. **Upload it**: `node routing/scripts/upload-road-graph.mjs` (1 MB floor / 80 MB ceiling pre-flight). 4. **Point the webhook**: `npm run bot:deploy` runs `wrangler deploy` + `scripts/set-webhook.mjs` automatically; or set it standalone. Steps 1–3 are spelled out fully in [ROAD-GRAPH.md](./ROAD-GRAPH.md) — that doc is the fork contract. The search gazetteer needs no provisioning: its dataset (`search/src/data/kl-gazetteer.json`) is committed. ### Pre-build before first run Both `npm run dev` and `npm run deploy` run the build first, but building by hand first gives you faster feedback: ```bash npm run build # theme -> web (this is the deployable artifact) ``` --- ## The dev loop ```mermaid graph TD classDef edit fill:#EB8008,stroke:#1A1A1A,stroke-width:2px,color:#1A1A1A classDef check fill:#D9D2C6,stroke:#1A1A1A,stroke-width:2px,color:#1A1A1A classDef gate fill:#ED3F1C,stroke:#1A1A1A,stroke-width:2px,color:#FFFFFF EDIT["Edit source
bot/src, web/src, theme/src,
search/src, routing/src"]:::edit TC["npm run bot:typecheck
npm run web:typecheck
npm run search:typecheck
npm run routing:typecheck"]:::check T1["npm run bot:test"]:::check T2["npm run web:test"]:::check T3["npm run search:test"]:::check T4["npm run routing:test"]:::check AU["npm run audit
off-token CSS audit"]:::check BUILD["npm run build
theme then web"]:::check DEV1["npm run bot:dev
wrangler dev :8787"]:::edit DEV2["npm run web:dev
vite :5173"]:::edit HARN["npm run web:build:harness
+ npm run web:capture"]:::check COMMIT["git commit
type(phase): subject"]:::gate EDIT --> TC EDIT --> T1 EDIT --> T2 EDIT --> T3 EDIT --> T4 EDIT --> AU TC --> BUILD T1 --> BUILD T2 --> BUILD T3 --> BUILD T4 --> BUILD AU --> BUILD BUILD --> DEV1 BUILD --> DEV2 BUILD --> HARN DEV1 --> COMMIT DEV2 --> COMMIT HARN --> COMMIT COMMIT --> EDIT ``` ### Bot workspace (`@araharah/bot`) ```bash npm run bot:dev # wrangler dev --port 8787 --local ``` Runs the Worker on . `--local` keeps everything on Miniflare: KV is emulated under `bot/.wrangler/` (gitignored) and nothing touches the production namespace. Secrets come from `bot/.dev.vars`. The Worker serves both the API and the Mini App static assets, so after changing anything under `web/` you must rebuild for `wrangler dev` to serve the new bundle: ```bash npm run build && npm run bot:dev ``` The cron trigger is exercisable locally under `wrangler dev`: ```bash curl "http://localhost:8787/cdn-cgi/local/scheduled" ``` ### Web workspace (`@araharah/web`) ```bash npm run web:dev # vite dev server on http://localhost:5173 ``` Vite is configured with `port: 5173` and `strictPort: true`, so it will fail rather than silently pick 5174. Important: **there is no Worker behind the Vite dev server.** `main.ts` calls relative paths (`/api/session`, `/api/location`), which 404 against plain Vite. For interactive work against a real API, either run `bot:dev` and open (the Worker serves the built bundle), or use the harness below. In `import.meta.env.DEV`, `main.ts` falls back to the mock Telegram WebApp so the shell still renders. ### Harness (Mock-Telegram) workflow The Mini App's UI can be driven without Telegram at all. The harness build swaps in `web/src/mock/telegram-mock.ts` plus `/api/*` fetch stubs: ```bash npm run web:build:harness # vite build --mode harness -> web/dist-harness/ npm run web:capture # Playwright screenshots of every boot/live/denied state ``` `npm run web:capture` serves `web/dist-harness` (never `web/dist` — the harness block is dead-code-eliminated from the production build) and writes `web/screenshots/*.png` (gitignored). It waits on the `RAMS-BASEMAP-READY` and `ARAHARAH-LIVE` console signals and runs the GEO-03 runtime storage probe in-page before declaring success. Harness URL parameters, documented in `web/index.html`: | Parameter | Values | Effect | | --- | --- | --- | | `?mock=` | `android`, `ios`, `tdesktop`, `macos`, `web` | Which Telegram client surface to simulate | | `?scheme=` | `light`, `dark` | Telegram color scheme | | `?pin=` | `1` | Drive the chat-pin relay link (`?pin=1` stubs `/api/location`) | | `?locale=` | `en`, `ms` | Force a locale | | `?sessiondelay=` | milliseconds | Delay the session handshake | | `?fixdelay=` | milliseconds | Delay the location fix | ### Search workspace (`@araharah/search`) ```bash npm run search:typecheck # tsc --noEmit npm run search:test # node test/runner.mjs — unit suites + corpus assertions npm run search:corpus # the golden-corpus bake-off (search/scripts/gazetteer-corpus.mjs) ``` `search:corpus` measures the pinned golden corpus against the SAME shared search module the bot and the Mini App import — exit 0 only when top-3 hit rate clears the corpus threshold AND every negative is refused (zero leaks). `corpus.test.mjs` asserts the same measurements in-process, so a corpus regression fails the suite too. Two further maintainer scripts regenerate the dataset when needed: `search/scripts/overpass-topup.mjs` (fetch more POIs) and `search/scripts/json-to-sqlite.mjs` — the committed dataset has an ODbL notice inside its envelope. ### Routing workspace (`@araharah/routing`) ```bash npm run routing:typecheck # tsc --noEmit npm run routing:test # node test/runner.mjs — codec, snap, A*, seams npm run routing:corpus # the offline tolerance gate (routing/scripts/route-corpus.mjs) npm run routing:fixtures # regenerate fixtures from the OSRM demo server (maintainer tool) npm run glyphs -w routing # render the turn-card PNGs into web/public/glyphs/ ``` `routing:corpus` is fully offline (zero network in the measurement path): it measures the committed fixture dataset against the same module the webview imports. `routing:fixtures` is the only step that touches the OSRM demo server — the committed fixtures were generated there, and the committed dataset is what ships. The graph binary track lives under `routing/data/` (see *Fork provisioning* above and [ROAD-GRAPH.md](./ROAD-GRAPH.md)); `build-road-graph.mjs` and `upload-road-graph.mjs` are maintainer tools, and `scripts/measure-route-cpu.mjs` feeds the budget audit's route-CPU gate. ### Theme workspace (`@araharah/theme`) ```bash npm run build -w theme # src/theme.css -> dist/rams.css (minified) npm run sheet # build, then open theme/src/sheet.html npm run gallery # build + capture, then serve gallery.html + basemap on :8123 npm run audit # off-token audit of the rendered CSS ``` `npm run audit` and `npm run capture` are the two pieces of design-system evidence tooling. `capture` renders `sheet.html` and the four read-only mockups via Playwright and writes `theme/screenshots/` (gitignored). `gallery` blocks on `python3 -m http.server 8123` serving the theme workspace, so stop it with `Ctrl-C` when done. Note that `npm run gallery` ends with a **blocking** `basemap:preview` server — it will appear to hang. That is expected. ### Registering bot commands The Telegram `/` menu is persistent server state, not something the Worker pushes. Register it explicitly after adding or renaming a command: ```bash npm run bot:commands # node scripts/register-commands.mjs ``` `bot/scripts/register-commands.mjs` reads `BOT_TOKEN` (from `process.env`, else from `bot/.dev.vars`), derives each description from the `helpMenu` lines in `bot/src/i18n/strings.ts`, and makes six `setMyCommands` calls (default en/ms, all_private_chats en/ms, and the two stale group-scope clears) — one for the default scope (English) and one for `language_code: "ms"` (Malay). Because the descriptions are *derived* from the same strings `/help` replies with, the menu and the reply cannot drift. If you add a command, you must add it to the `menuFor()` command array **and** to the corresponding `helpMenu` lines — the script throws `helpMenu for "" is missing its / line (D-03 copy drift)` if the two disagree. The gate `CS5 D-03: register-commands derives both scopes from helpMenu` in `bot/test/commands.test.mjs` enforces this. Only re-run this when the command *list or its descriptions* change. Repointing the webhook is a separate script (`set-webhook.mjs`), run automatically by `npm run deploy`. `scripts/set-menu-button.mjs` sets the BotFather menu-button deep link (separate server state, rerun after origin changes). --- ## Build and check commands ### Root scripts (`package.json`) | Command | Description | | --- | --- | | `npm run build` | Build `theme`, then `web`. Produces the deployable artifact. | | `npm run dev` | `build`, then `bot:dev` — the one-command local app loop. | | `npm run deploy` | `build`, then `bot:deploy` (`wrangler deploy` + `set-webhook.mjs`). | | `npm run privacy-audit` | The consolidated privacy audit — five static gates + `--live` probes (`node scripts/privacy-audit.mjs`). | | `npm run budget-audit` | The consolidated free-tier budget audit — six measurement gates + the comparison table (`node scripts/budget-audit.mjs`). | | `npm run bot:test` | `npm test -w bot` — the bot's hand-rolled suite. | | `npm run bot:typecheck` | `tsc --noEmit` in `bot`. | | `npm run bot:dev` | `npm run dev -w bot` — `wrangler dev --port 8787 --local`. | | `npm run bot:deploy` | `wrangler deploy && node scripts/set-webhook.mjs`. | | `npm run bot:commands` | Register the Telegram command menu (EN + MS). | | `npm run web:test` | `npm test -w web` — the Mini App suite. | | `npm run web:typecheck` | `tsc --noEmit` in `web`. | | `npm run web:dev` | `npm run dev -w web` — Vite dev server on 5173. | | `npm run web:build` | Alias of `build` (theme then web). | | `npm run web:build:harness` | Harness-mode build into `web/dist-harness/`. | | `npm run web:capture` | Playwright screenshots of the Mini App states. | | `npm run search:test` | `npm test -w search` — the gazetteer suite (incl. corpus assertions). | | `npm run search:typecheck` | `tsc --noEmit` in `search`. | | `npm run search:corpus` | The golden-corpus search bake-off. | | `npm run routing:test` | `npm test -w routing` — the routing suite (codec, snap, A\*, seams). | | `npm run routing:typecheck` | `tsc --noEmit` in `routing`. | | `npm run routing:corpus` | The offline route-tolerance gate. | | `npm run routing:fixtures` | Regenerate committed fixtures from the OSRM demo server (maintainer tool). | | `npm run sheet` | Passthrough to the theme workspace. | | `npm run gallery` | Passthrough to the theme workspace. | | `npm run audit` | Passthrough to the theme workspace — off-token CSS audit. | | `npm run capture` | Passthrough to the theme workspace — token-sheet screenshots. | ### Workspace-local scripts | Command | Where | Description | | --- | --- | --- | | `npm test` | `bot`, `web`, `search`, `routing` | `node test/runner.mjs` — zero-dependency, auto-discovers `*.test.mjs`. | | `npm run typecheck` | `bot`, `web`, `search`, `routing` | `tsc --noEmit`, `strict` + `noUncheckedIndexedAccess`. | | `npm run dev` | `bot` | `wrangler dev --port 8787 --local`. | | `npm run deploy` | `bot` | `wrangler deploy && node scripts/set-webhook.mjs`. | | `npm run commands` | `bot` | `node scripts/register-commands.mjs`. | | `npm run build` | `web` | `vite build` → `web/dist`. | | `npm run build:harness` | `web` | `vite build --mode harness` → `web/dist-harness`. | | `npm run capture` | `web` | `node scripts/capture.mjs`. | | `npm run corpus` | `search` | `node scripts/gazetteer-corpus.mjs`. | | `npm run corpus` | `routing` | `node scripts/route-corpus.mjs`. | | `npm run fixtures` | `routing` | `node scripts/fetch-route-fixtures.mjs` (network — OSRM demo; maintainer tool). | | `npm run glyphs` | `routing` | `node scripts/build-glyphs.mjs` — render `web/public/glyphs/*.png`. | | `npm run build` | `theme` | `tailwindcss -i ./src/theme.css -o ./dist/rams.css --minify`. | | `npm run send` | `theme` | Copy self-hosted `woff2` fonts into `dist/fonts/`. | | `npm run basemap` | `theme` | `node basemap/build.mjs` — derive `basemap/liberty-rams.json` from tokens. | | `npm run basemap:preview` | `theme` | `python3 -m http.server 8123` (blocking). | | `npm run audit` | `theme` | `node ./scripts/audit.mjs ./dist/rams.css`. | | `npm run capture` | `theme` | `node ./scripts/screenshot.mjs`. | | `npm run test` | `theme` | `node ./scripts/audit.test.mjs`. | Maintainer scripts **without** an npm alias (run with `node `): the road-graph track `routing/scripts/build-road-graph.mjs` and `routing/scripts/upload-road-graph.mjs`, the dataset tools `search/scripts/overpass-topup.mjs` and `search/scripts/json-to-sqlite.mjs`, and the CPU ladder `routing/scripts/measure-route-cpu.mjs` (also invoked by `budget-audit`'s route-CPU gate). ### Before you open a change Run the four typechecks and the four test suites. They are fast and they are the project's actual quality gates: ```bash npm run bot:typecheck npm run web:typecheck npm run search:typecheck npm run routing:typecheck npm run bot:test npm run web:test npm run search:test npm run routing:test npm run audit # only if you touched theme/ or any rendered CSS ``` `theme` has its own suite (`npm test -w theme` → `node ./scripts/audit.test.mjs`). Note it has **no root passthrough script** — you must use the `-w theme` form to run it, unlike the other four workspaces. As a release gate, run the two consolidated audits (they print evidence lines per gate; exit 2 means NOT RUN and is never a pass): ```bash npm run privacy-audit # add --live when credentials + a build exist npm run budget-audit ``` --- ## Code style **There is no linter and no formatter configured in this repository.** No ESLint, no Prettier, no Biome, no `.editorconfig`, no `lint` or `format` script in any of the five `package.json` files, and no commit hooks (no Husky, no `.git/hooks` wiring). If you add one, it becomes the project's standard retroactively — but today style is a review-time convention, not an automated check. What *is* mechanically enforced is TypeScript strictness and the gate suites described below. ### TypeScript All four TS workspaces (`bot`, `web`, `search`, `routing`) are strict beyond the default. The settings that actually change how you write code: | Setting | `bot` | `web` | `search` / `routing` | Why it matters | | --- | --- | --- | --- | --- | | `strict` | yes | yes | yes | No implicit `any`, strict null checks. | | `noUncheckedIndexedAccess` | yes | yes | yes | Indexing an array/record yields `T \| undefined` — you must narrow. This is the single most common source of compile friction in this repo. | | `verbatimModuleSyntax` | yes | yes | yes | Type-only imports must use `import type`. | | `allowImportingTsExtensions` | yes | yes | yes | Import with explicit `.ts` extensions is allowed and is the house style. | | `noEmit` | yes | yes | yes | `typecheck` is `tsc --noEmit`; bundling is wrangler's or Vite's job. | | `target` / `lib` | ES2023 / ES2023 | ES2023 / ES2023 + DOM | ES2023 / ES2023 | No downleveling. The data planes are DOM-free. | | `types` | `@cloudflare/workers-types` | `vite/client` | none | Determines which globals exist. `bot` has no DOM; `web` does; the data planes are V8-native. | | `resolveJsonModule` | no | yes | yes | `web` imports the basemap JSON style; the data planes load their committed datasets via static JSON imports. | `moduleResolution` is `"bundler"` in all four — deliberately not `node16`, because the runtimes are bundler-hosted (wrangler / Vite) or Node type-stripping (the corpus runners), not raw Node + CommonJS. ### House conventions observable in the source - **Explicit `.ts` extensions on relative imports.** e.g. `import { strings } from "../src/i18n/strings.ts"`, `from "./harness.mjs"`. Data-plane consumers import the subpath with extension too (`@araharah/search/src/index.ts`) — a bare package specifier resolves to nothing (no `exports`/`main` field). - **`.mjs` for scripts and tests, `.ts` for source.** Everything in `bot/scripts/`, `bot/test/`, `web/test/`, `web/scripts/`, `search/scripts/`, `search/test/`, `routing/scripts/`, `routing/test/`, `theme/scripts/`, and root `scripts/` is `.mjs` and runs under plain Node. - **`node:` prefixed builtins** — `import { readFileSync } from "node:fs"`, never bare `"fs"`. - **Heavy header comments.** Every script and several source modules open with a block comment stating what it is, which decision IDs it satisfies, and its known constraints. Match that when adding a file. - **Named constants in SCREAMING_SNAKE for tunables** — `SESSION_TTL_SECONDS = 900`, `PIN_TTL_SECONDS = 300`, `GUIDANCE_SESSION_TTL_SECONDS = 7200`, `CARD_CADENCE_S = 240`. The privacy gate greps for this exact shape (see below). - **Zero inline user-facing copy.** All strings live in `bot/src/i18n/strings.ts` and `web/src/i18n/strings.ts`, keyed by locale, with `en` and `ms` carrying identical key-sets. Gate: `COPY5 no shell module inlines copy`. - **Structural interfaces for injection.** `KvLike`, `TelegramWebAppLike`, `acquireFix(deps, …)`, `createGuidanceWatch(geo, …)`, `HttpRoutingSourceOptions` — dependencies are passed in so tests can supply fakes without touching Workers types or `window`. - **No runtime deps in the data planes.** `@araharah/search` and `@araharah/routing` are V8-native and dependency-free; `routing/scripts/` may use dev tools (`sharp` for glyph rendering) but the library code never does. --- ## Project conventions (the gates) These are the rules the codebase enforces on itself. Each is a real test that reads source or build artifacts — treat them as non-negotiable, because a change that breaks one is a regression even if the behavior looks fine. | Gate | Suite | Rule | | --- | --- | --- | | `PG1` | `bot/test/privacy.test.mjs` | Every `.put(` line in `kv/sessions.ts`, `kv/location-pin.ts`, `kv/guidance-sessions.ts`, and `webhook.ts` must carry `expirationTtl` **on the same line**, and the value must be a named `SCREAMING_SNAKE` constant — never an inline literal. No TTL-free write path can ship. The same law is asserted repo-wide by the `kv-ttl` gate in the consolidated privacy audit. | | `PG2` | `bot/test/privacy.test.mjs` | The KV modules export nothing persistence-shaped: no `saveProfile`, `storeProfile`, `saveLocation`, `storeLocation`, `saveHistory`, `storeHistory`, `putHistory`, `setLocation`. | | `GEO3` | `web/test/no-storage.test.mjs` | After stripping comments, no `web/src` file may reference `localStorage`, `sessionStorage`, `indexedDB`, `caches`, `CacheStorage`, `document.cookie`, `WebApp.DeviceStorage`, `WebApp.SecureStorage`, or `WebApp.CloudStorage`. Also no `serviceWorker.register`, `navigator.serviceWorker`, or `workbox`. The `storage-api` gate generalizes this repo-wide. | | `WK5` | `bot/test/webhook.test.mjs` | An empty `WEBHOOK_SECRET` must fail the webhook **closed** with a constant 500, without running the bot or touching KV. | | `CS4` | `bot/test/commands.test.mjs` | All replies go out with link previews disabled and nothing else attached; the command tree has no reply-menu or inline-button surface — except the two documented exceptions (the location reply keyboard and the `/cari` venue card). | | `CS6` | `bot/test/commands.test.mjs` | The command stack registers exactly `/start`, `/mula`, `/help`, `/privacy`, `/privasi`, `/cari`, `/search`. | | `COPY1` | `web/test/copy-gate.test.mjs` | `en` and `ms` carry identical key-sets (locale parity). | | `COPY5` | `web/test/copy-gate.test.mjs` | No module under `web/src/ui/*.ts` or `web/src/main.ts` inlines copy strings. | | `BA1`–`BA6` | `web/test/build-artifact.test.mjs` | The production bundle references no third-party CDN, self-hosts its `woff2` fonts, contains a hashed MapLibre worker chunk, carries the inlined Rams style JSON, and contains **no** Mock-Telegram installer or runner-page fetch stub in any emitted file. | | `R12` | `web/test/guidance-replay.test.mjs` | The guidance state machine is pure and replay-verifiable: every behavior is decided in `guidance.ts`, and its structural case gates that the module contains no console, no document, no maplibre, no localStorage. | | route-privacy | `bot/test/route-privacy.test.mjs` | The `/api/route` handler persists nothing and logs nothing beyond categories — the origin coordinate never touches storage or a log line. | | audit exit codes | `theme/scripts/audit.mjs` | `0` clean, `1` off-token violations, `2` token-sheet drift. No silent passes. | ### The consolidated audits (release gates) Two root tools carry the same laws beyond the suites — both are maintainer-time repo tools (nothing in `bot/src`, `web/src`, `routing/src`, or `search/src` imports them), both follow the same contract: one evidence line per gate, exit `0` clean / `1` violations / `2` NOT RUN (environment drift — a skipped probe is NEVER mistaken for a pass), category-only output (never a coordinate, id, token, or payload). Both honor a `PRIVACY_AUDIT_ROOT` override and are cwd-independent, and both double as the fork onboarding check. **`npm run privacy-audit`** (`scripts/privacy-audit.mjs`, gates in `scripts/lib/source-gates.mjs` + `scripts/lib/bundle-gates.mjs`): | Gate | Rule | | --- | --- | | `storage-api` | The memory-only law, repo-wide. | | `kv-ttl` | The PG1 same-line TTL law, repo-wide. | | `log-vocabulary` | The D-14 category-only log law. | | `runtime-hosts` | The POSITIVE fetch-host allowlist — no fetch outside it. | | `bundle-telemetry` | The production `web/dist` scan — no analytics/beacon/telemetry. | | `kv-expiry` / `asset-headers` (`--live`) | Live KV expiry metadata + cache headers, read verbatim. | **`npm run budget-audit`** (`scripts/budget-audit.mjs`, gates in `scripts/lib/budget-gates.mjs`): | Gate | Rule | | --- | --- | | `route-cpu` | Re-runs the A\* CPU ladder (child process) — inside its budget or a documented, decided escalation. | | `bundle-budget` | Route-payload gzip + whole-app bundle gzip ceilings. | | `kv-writes` | Write classes + the cron's ZERO-writes law. | | `subrequests` | The per-request-class subrequest inventory. | | `golden-corpus` | Re-runs `routing:corpus` + `search:corpus`. | | `cache-headers` (`--live`) | Observed cache headers, verbatim. | The human record for both lives in `docs/BUDGETS.md` and `docs/PRIVACY-AUDIT.md`. The same privacy promise is asserted from both sides: the **bot** must never write without a TTL, and the **web** client must have no storage surface at all. If you add a persistence mechanism, these suites fail — which is the point. ### The three invariants worth internalizing **Access control.** The `chat_id` for `/api/location`, `/api/location-fallback`, and `/api/guidance` is *always* resolved from the session claim, never from client input; locale comes from `claim.language_code`; the fallback request body is never read and a guidance body's `chat_id` is structurally dropped. `/api/route` uses the claim only as an identity gate and persists nothing about the caller. If you add a route, derive identity the same way. **Honest degradation.** Every failure is a labeled state, never a silent one and never a fabricated value. A `null` heading hides the marker cone instead of guessing; `getPin` range-validates so a corrupt record cannot fly the camera to null island; the chat-pin poll is bounded at 60 s so `AWAITING PIN` cannot persist forever; every source timeout resolves `null` so the chain always advances; `bootMap()` returning `null` renders the shell with `data-map="absent"`; every `/api/route` miss class answers `200 null` (the honest router-pending card); a null road graph degrades a cron card to the street-less bare verb but still sends. Copy along this pattern: if you cannot do the thing, say so in a state, don't fake it. **The backend ladders are import-site code.** Search and routing backends are selected by constructing them at the import site — no env read, no config, no registry. If you add a backend rung (e.g. engine B), it slots beneath the existing seam and the ladder documentation updates in `ARCHITECTURE.md`/`docs/SEARCH-QUALITY.md` — never a runtime switch. ### Design-token workflow `theme/src/tokens.css` is the **single source of truth** for every color, spacing, typography, and radius value in the project. It declares them in a Tailwind v4 `@theme static` block (55 `--color-*` tokens plus spacing and typography); `static` forces every token into the compiled CSS so the package is consumable as plain custom properties by anything, including the MapLibre basemap derivation. Rules: 1. **Never hardcode a value that belongs to a token.** Not a hex, not a px padding, not a font stack. `npm run audit` will flag it in rendered CSS. 2. **Never restate token values in documentation.** `DESIGN.md` and other docs describe principles and point at the token package. 3. **Never edit `stitch_minimalist_route_navigator/`.** Those four mockups are read-only reference; `theme/src/theme.css` `@source`-scans them to compile their utility vocabulary, and `theme/scripts/screenshot.mjs` falls back to their committed `screen.png` on capture failure. 4. **Adding a token** means editing `tokens.css`, rebuilding (`npm run build -w theme`), and re-running `npm run audit`. If it is a cartographic color, also consider whether `theme/basemap/build.mjs` should derive it. 5. **`theme/src/theme.css`** is the entry stylesheet: it `@import`s tailwindcss, `tokens.css`, and `components.css`, declares seven self-hosted `@font-face` rules from `theme/fonts/` (no Google Fonts CDN), scopes class generation with explicit `@source` lines (the four mockups are the allowed utility sources; `@source not "../scripts"` and `@source not "../basemap"` exclude prose), and defines the `--shell-bg` / `--shell-on-surface` theme slots. 6. **The theme-slot machine.** Light/dark is a slot switch, not a color import: `web/src/telegram/theme.ts` reads Telegram's signal (or the `header_bg_color` luminance fallback) and its `applyTheme`'s ONLY statement is the `data-tg-theme` attribute set — CSS custom properties re-slot between the two Rams token pairs and the MapLibre style is never re-set. `web/test/theme.test.mjs` greps that invariant; don't fight it. 7. **`theme/basemap/build.mjs`** forks OpenFreeMap `liberty` and derives every cartographic color from `tokens.css` via `desaturate(...)`, with an accent guard that fails the build if `#EB8008`, `#ED3F1C`, or `#736B1E` leak into any layer. `building-3d` is stripped (no faux 3D). --- ## Branch and commit conventions ### Branches The repo's only active branch is `main` (there is a stray `master` but it is not the active branch; the remote is `origin/main`), and there is **no remote branch convention documented** — no `CONTRIBUTING.md`, no PR template, no CI config (no `.github/`, no `.gitea/`). In practice, work lands on `main` via atomic commits rather than long-lived feature branches. If you prefer branches, `feat/`, `fix/`, and `docs/` prefixes match the commit vocabulary below. ### Commit messages The convention is **conventional-commit style with a phase-scoped type**: `type(phase): subject`. The type is one of `feat`, `fix`, `docs`, `chore`, `test`. The scope in parentheses is a phase identifier, sometimes with a sub-task (`03-04`). Examples straight from `git log`: ``` feat(03-03): status strip, state cards, recenter control, boot choreography — map-first, D-09 staged states fix(03-04): load telegram-web-app.js in head — production WebApp was never defined docs(03-04): log debug round 5 — diagnostic probe armed test(02-01): hand-rolled harness + RED initData vector suite (D-12, TDD RED) chore(03-01): lockfile resolution for the web workspace fix(02-02): ... (when the scope names the phase area rather than a number) ``` The subject is lowercase after the colon, imperative, and — the house habit — carries an em-dash clause explaining the *why* or the observable failure it fixes ("production WebApp was never defined (no initData…)"). Reference decision IDs (`D-09`, `WR-03`, `PRIV-02`) are appended in parentheses when relevant. Keep commits atomic: the log shows one concern per commit, frequently a `fix(...)` immediately after the `docs(...)` log entry that motivated it. --- ## Pull requests **There is no PR process configured** — no `.github/PULL_REQUEST_TEMPLATE.md`, no CONTRIBUTING file, no CI, no required reviewers. Given the private single-branch setup, changes are typically committed straight to `main`. If you do open a PR, match what the project actually relies on: - **Run the gates** and paste the output: all four typechecks, all four test suites, `npm run audit` if any rendered CSS or theme changed, and — for anything privacy- or budget-relevant — `npm run privacy-audit` / `npm run budget-audit`. - **Explain the observable before/after**, following the commit-subject habit (what was broken, what it does now). - **Call out gate-relevant surface**: if you touched a KV write, `web/src` persistence, the command surface, the i18n strings, or the guidance envelope, say which gates you re-verified (`PG1`, `GEO3`, `CS6`, `COPY1`, `R12`…). - **Include screenshots** for any Mini App change — `npm run web:build:harness && npm run web:capture` produces the current states. - **Do not commit build output.** `theme/dist`, `web/dist`, `web/dist-harness`, `routing/data/*.bin`, and the `screenshots/` directories are gitignored by design; they are regenerated at build time. --- ## Committing generated artifacts `dist/` and the screenshot directories are **never committed**. This is a deliberate rule, called out in `.gitignore`: the web build consumes `theme/dist/rams.css` at build time without vendoring, so a committed `theme/dist` would go stale. Always rebuild (`npm run build`) rather than expecting a checked-in artifact. `routing/data/kl-roadgraph.v1.bin` is likewise a build artifact (`routing/data/.gitignore` ignores `*.bin` — it is ODbL-derived and per-fork), and `web/dist-harness/` exists only for capture runs. `.planning/` is git-TRACKED (the ignore line is commented out; planning artifacts are part of the repo) (local-only planning docs), as is `bot/.dev.vars` and `bot/.wrangler/`.