- source-side runtime-hosts gate gains DOC_POINTER_STATIC_TEXT_HOSTS (the MapLibre-attribution precedent): the published repo origin may appear ONLY as a bare URL literal in bot copy (D-08/D-09), never fetched - class is NARROW and pinned by SA6: a fetch( target naming the same host is still a violation - Rule 1 (found by SA6's RED): the fetch( handler's declaration heuristic misclassified any quoted absolute URL as a TS declaration — quoted strings are targets; they now reach the host check - docs/PRIVACY-AUDIT.md: adjudication documented + dated re-run addendum - deferred-items.md #1 closed: npm run privacy-audit exits 0 again (PRIVACY AUDIT CLEAN — 5 gate(s) passed, 0 violations; suite 13/13)
13 KiB
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 |
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. The SOURCE-side runtime-hosts gate carries the same kind of adjudication for
the /help Known-limits doc pointer (see the 2026-10-25 addendum below): the published repo
origin appears ONLY as a bare URL literal in the bot strings — user-clicked navigation, never
fetched — and the class is NARROW (a fetch( target naming the same host is still a violation;
pinned by the SA6 vector).
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 <script> tag in web/index.html |
| the app's own Worker origin | fetch allowlist (relative) | /api/session, /api/location, /api/location-fallback, /api/guidance, /api/route — RELATIVE paths, not hosts |
maplibre.org, wiki.openstreetmap.org, github.com, maplibre.invalid |
documented static-text class | MapLibre's own attribution/diagnostic constants inside the bundled library — displayed text, never fetched |
opendatacommons.org |
documented static-text class | our own gazetteer/fixture ODbL license metadata strings |
www.w3.org |
documented spec identifiers | SVG/XHTML namespace constants — never fetched |
gitmirah (the published repo origin) |
documented static-text class (SOURCE-side, URL literals only) | the /help Known-limits doc pointer in bot/src/i18n/strings.ts (D-08/D-09) — user-facing copy in a chat card, never fetched; a fetch( to the same host is still a violation (SA6) |
ZERO telemetry (D-02): no analytics, no beacons, no tracking pixels, no third-party CDN, no error-reporting host, no social pixels — the shipped bundle contains no analytics or beacon token of any kind, and no fetch to any host outside the three-row fetch allowlist above. v1 has nothing to opt into: opt-in telemetry stays an IDEAS.md aspiration post-validation.
Statement
PRIV-02 (storage is TTL-only, per-user data never outlives its window) and GEO-03 (the client
persists nothing; coordinates live in memory client-side and in a 300 s TTL pin server-side) are
re-verified END-TO-END by this audit, not asserted: the storage-api gate scans all 72 files
across the five production trees (comments stripped) for any persistence API or service-worker
surface; the kv-ttl gate proves all 4 KV write paths carry a same-line expirationTtl
referencing a named constant; the log-vocabulary gate machine-verifies that none of the 28 log
call sites can carry a coordinate, an identifier, or a payload; the runtime-hosts gate proves
the source tree only ever fetches the app's own Worker; and the bundle-telemetry gate proves
the SHIPPED bundle — the artifact that actually decides the app's runtime network — contains
zero analytics, zero beacons, zero storage APIs, zero deep-link crypto, and no fetch host beyond
the OpenFreeMap tiles, the Telegram SDK, and the Worker itself. The live legs (KV listing with
expiry metadata, deployed cache headers) were observed NOT RUN in this environment and are
re-run with npm run privacy-audit -- --live at the launch gate — exit 0 only when they PASS,
never when they are skipped.
2026-10-25 — Phase 7 gate-prep re-run: the /help doc pointer adjudication
Why this addendum exists: plan 07-05's committed /help Known-limits pointer (commit
78482ab) put the repo's actual published-origin URL (http://gitmirah:3000/c-fu/arah2/…,
the maintainer's Gitea remote per git remote -v) into the bot strings (en + ms), and the
runtime-hosts gate — a positive allowlist with no static-text escape — flagged it as 2
violations (PRIVACY AUDIT FAILED). Logged in the phase's deferred-items.md; fixed by the
07-06 gate prep, following the audit's OWN laws (the MapLibre static-text adjudication
precedent), not by weakening them.
The fix (source-side, scripts/lib/source-gates.mjs):
- A documented
DOC_POINTER_STATIC_TEXT_HOSTSclass on the SOURCE-sideruntime-hostsgate: the published repo origin may appear ONLY as a bare URL literal in bot copy (the /help Known-limits line — user-clicked navigation, never fetched). Each entry names its reason; forks replace it with their own published origin. - The class is NARROW by construction and pinned by the SA6 vector: it is consulted ONLY
for bare URL literals. A
fetch(target naming the same host is STILL a violation. This required fixing a latent gate bug (SA6's RED proved it): thefetch(call-site handler's declaration heuristic (argText.includes(":")) misclassified any QUOTED absolute URL as a TS method declaration and skipped it — a quoted string is always a target, so quoted fetch targets now reach the host check. - Result:
npm run privacy-audit→runtime-hosts: PASS — host inventory (printed, not assumed): www.w3.org×3, gitmirah×2, 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] or the documented doc-pointer static-text class (URL literals only — never fetched)→ PRIVACY AUDIT CLEAN — 5 gate(s) passed, 0 violations; the suite is 13/13 (SA1–6, BG1–2, CP1–2, AU1–3). The Findings table above remains the 07-03 run's historical record; this section is the gate-time re-run record.
Driver-resolution caveat (unchanged from 07-05's decision): the URL resolves for a driver only once the D-09 launch checklist's "repo pushed public" step lands main on the remote — the launch gate verifies the pointer line resolves before signing.
Signature
The audit record is SIGNED BY THE HUMAN at the launch gate (the tool produces the evidence; the human signs the claim):
Maintainer: ________________________________
Date: ________________________________