# Privacy audit record **PRIVACY AUDIT RECORD** — the signed written audit record citing the evidence (Phase 7, D-01). | Field | Value | | --- | --- | | Audit date | 2026-10-24 | | Audited tree | commit `9c55e9a` (`npm run privacy-audit` run against that working tree) | | Auditor | the maintainer — signature block below (the tool produces the evidence; the human signs the claim) | | Tool | `npm run privacy-audit` (`scripts/privacy-audit.mjs` + `scripts/lib/*`) — GPL-3.0, reusable by forks | | Result | **PRIVACY AUDIT CLEAN — 5 gate(s) passed, 0 violations** (static gates); live legs recorded as NOT RUN below | The tool is a MAINTAINER-TIME REPO TOOL — nothing in any `src` tree imports it. A fork reruns `npm run privacy-audit` over its own trees before its first driver; this record is the v1 tree's own evidence. --- ## Scope **Audited:** | Surface | What was scanned | | --- | --- | | All five `src` trees | `web/src`, `bot/src`, `routing/src`, `search/src`, `theme/src` — 72 code files (`.ts`/`.tsx`/`.js`/`.mjs`/`.html`), comments stripped (the canonical stripper, copied verbatim from `web/test/no-storage.test.mjs`) | | Production bundle | `web/dist` — every emitted `.js` and `.html` file (PRODUCTION only) | | Live KV namespace | `chat_sessions` (`bot/wrangler.jsonc`) — TTL-only keys, probed live only | **Not audited (deliberate):** | Excluded surface | Why | | --- | --- | | `web/dist-harness` | the test-harness build is deliberately NOT clean (BA5's law — a gate pointed at it inverts itself) | | The Telegram client's own internal storage | the official `telegram-web-app.js` is EXTERNAL to the bundle; its `__telegram__initParams` sessionStorage write is the platform allowlist, not ours | | `web/src/mock/telegram-mock.ts` runtime behaviour | scanned by the source gates (it is in `web/src`); its in-page demo interactions are harness-only and never ship | | Third-party library internals beyond the shipped bundle text | the bundle scan covers the shipped artifact itself — that IS the library's runtime footprint | ## Findings One row per gate; the Evidence column is the ACTUAL printed evidence string from the run (copied verbatim, never invented): | Gate | Result | Evidence | | --- | --- | --- | | `storage-api` | PASS | 72 file(s) across 5 src tree(s) — no persistence API outside the platform allowlist | | `kv-ttl` | PASS | 4 KV write(s) across 72 file(s) — every .put( line carries expirationTtl referencing a named ^[A-Z_]{2,}$ constant | | `log-vocabulary` | PASS | 21 bot + 7 web console.log call site(s) — every bot log opens with a bracketed surface token and carries NO lat/lng/chat_id/user_id/token/initData/geometry/polyline/payload carrier, every web log is an ARAHARAH-* state token or the RAMS-BASEMAP-READY signal, and no log serializes a payload | | `runtime-hosts` | PASS | host inventory (printed, not assumed): www.w3.org×3, telegram.org×1; 4 relative fetch target(s) to the app's own Worker, 1 fetch declaration(s) skipped; every absolute URL host is in the positive allowlist [tiles.openfreemap.org, telegram.org] | | `bundle-telemetry` | PASS | 3 JS + 1 HTML emitted file(s) scanned (PRODUCTION web/dist only — dist-harness forbidden); host inventory (printed, not assumed): maplibre.invalid×1, github.com×3, wiki.openstreetmap.org×1, maplibre.org×2, tiles.openfreemap.org×5, opendatacommons.org×2, telegram.org×1; every absolute-URL host is in the allowlist [tiles.openfreemap.org, telegram.org] or the documented static-text class; ZERO analytics/beacon/crypto/storage tokens | | `kv-expiry` (live) | NOT RUN | (pass --live) — no Cloudflare credentials in the auditing environment; the live leg re-runs at the launch gate with `npm run privacy-audit -- --live`. A skip is never a pass (exit code 2, not 0) | | `asset-headers` (live) | NOT RUN | (pass --live) — same re-run protocol; the live leg observed the HTML entry answering `cache-control: public, max-age=0, must-revalidate | cf-cache-status: HIT` and the then-deployed asset answering 404 (deployment drift — the deployed Worker pre-dates the current `web/dist`), which the probe reports as deployment drift, never as a pass | Documented scope adjudications (deliberate; each names its reason): the `beacon` token is checked only as `navigator.sendBeacon`; the MapLibre library's own static-text URLs (`maplibre.org`, `wiki.openstreetmap.org`, `github.com` diagnostics, `maplibre.invalid` protocol-test placeholder) and our own dataset's ODbL license strings (`opendatacommons.org`) are printed in the inventory as a documented static-text class — never fetched; `http://www.w3.org/{2000/svg,1999/xhtml}` are W3C spec identifiers. ## Storage inventory The FOUR TTL-only KV key families — the complete storage surface. No other family exists, and no coordinate or profile is persisted anywhere else (the `kv-ttl` gate proves every write path, the `storage-api` gate proves the client carries no persistence API at all): | Family | Prefix | TTL constant | TTL | What is stored | | --- | --- | --- | --- | --- | | session | `session:` | `SESSION_TTL_SECONDS` (`bot/src/kv/sessions.ts`) | 900 s | the 4-field `SessionClaim` — user_id, chat_id, issued_at, language_code. Nothing else ever serialized (D-08: a 5th property means drift) | | chat pointer | `chat:` | `SESSION_TTL_SECONDS` (`bot/src/kv/sessions.ts`) | 900 s | the current opaque session token for a chat | | location pin | `loc:` | `PIN_TTL_SECONDS` (`bot/src/kv/location-pin.ts`) | 300 s | Telegram's Location fields only — latitude, longitude, horizontal_accuracy (GEO-03: the ONLY place a coordinate exists server-side, and it expires) | | guidance envelope | `guidance:` | `GUIDANCE_SESSION_TTL_SECONDS` (`bot/src/kv/guidance-sessions.ts`) | 7200 s | the frozen 4-field envelope + inline 6-field route snapshot; nothing outlives 2 h, nothing reconstructable after expiry (D-07) | Every write path is pinned by the same-line `expirationTtl` law (PG1, extended repo-wide by the `kv-ttl` gate), and the cron performs ZERO KV writes (the free-plan budget law). The client (`web/src`) holds route/guidance state in MEMORY ONLY — no localStorage, no sessionStorage, no IndexedDB, no CacheStorage, no cookies, no service worker. ## Log vocabulary Category-only, machine-verified by the `log-vocabulary` gate. Allowed log surfaces: | Tree | Allowed log shape | | --- | --- | | `bot/src` | `console.log("[surface] verdict: category")` — surface is one of `webhook`, `initdata`, `location`, `location-fallback`, `guidance`, `guidance-cron`, `route`, `search`, `search-alternate`; every call site opens with a bracketed surface token | | `web/src` | the `ARAHARAH-*` state tokens (`ARAHARAH-DEST state=…`, `ARAHARAH-LIVE fix=…`, `ARAHARAH-ROUTE state=…`, `ARAHARAH-GUIDE state=…`, `ARAHARAH-DRIVE state=…`) or the `RAMS-BASEMAP-READY layers=…` signal; `console.error` on the basemap error path | The coordinate-free law: no log line may carry a coordinate, a chat id, a user id, a token, an initData payload, a geometry/polyline, or any serialized payload (`JSON.stringify` inside a log argument is forbidden outright). 21 bot + 7 web call sites verified. ## Runtime network The ACTUAL host inventory the audit printed (source scan + production bundle scan — produced, not assumed, A4): | Host | Class | Why it is reachable | | --- | --- | --- | | `tiles.openfreemap.org` | **fetch allowlist** | the basemap tiles / sprites / glyphs (`theme/basemap/liberty-rams.json`) | | `telegram.org` | **fetch allowlist** | the official Telegram WebApp SDK `