diff --git a/.planning/PROJECT.md b/.planning/PROJECT.md
new file mode 100644
index 0000000..8a37663
--- /dev/null
+++ b/.planning/PROJECT.md
@@ -0,0 +1,126 @@
+# araharah
+
+## What This Is
+
+A privacy-respecting GPS navigation app delivered through Telegram: a waze-like
+route navigator, but Telegram-first instead of an app store install, built on
+OpenStreetMap data, and designed to Dieter Rams' principle of "less, but better".
+Users search a destination (e.g. "KLCC") with the bot, tap into a Telegram Mini
+App (webview hosted on Cloudflare Pages), and receive live route guidance while
+driving. First users are in the Kuala Lumpur metro area.
+
+Reference design docs: `DESIGN.md` (design philosophy), `IDEAS.md` (vision +
+stack), `stitch_minimalist_route_navigator/` (4 UI mockup screens), and
+`.planning/codebase/` (codebase map; current state is design-only, no app code).
+
+## Core Value
+
+A user finds a local destination via Telegram and gets there with reliable
+turn-by-turn guidance — tracked by nothing beyond what they explicitly opted in
+to.
+
+## Requirements
+
+### Validated
+
+
+
+(None yet — no app code exists; project is greenfield-brownfield hybrid with
+design assets only)
+
+### Active
+
+
+
+- [ ] User can open the araharah Telegram bot and start the app (/start, menu)
+- [ ] User can search a destination by query and get a matching local address/POI
+- [ ] User can open the Telegram Mini App (Cloudflare Pages webview) from the bot
+- [ ] Mini App obtains the user's current location
+- [ ] User sees a chosen route (or short list of routes) to the destination
+- [ ] User receives live guidance while driving (in-app live map when
+ foregrounded; chat turn-cards / map snapshots when backgrounded — hybrid)
+- [ ] The experience visually matches the Telemetry Rams design system
+ ("design fidelity" — flagged as the top risk)
+
+### Out of Scope
+
+
+
+- Incident reports (/accident, /traffic, /hazard + broadcasts) — navigation
+ first; reports ship after the core works
+- Gamification, points, leaderboards — depends on reports; deferred with them
+- Regional clusters via Telegram groups — unvalidated social hypothesis
+- Voice-to-text reporting, offline mode, anonymized public API — future ideas
+- Push notifications infra beyond Telegram chat (Resend email etc.) — Telegram
+ itself is the channel
+- Payment/monetization nothing planned
+
+## Context
+
+- Telegram bot is the primary UX: command menu, destination search, Mini App
+ launch. Mini App should be a proper Telegram WebApp (Telegram WebApp JS API).
+- OSM ecosystem: OpenStreetMap data (geocoding must understand KL landmarks like
+ "KLCC"), routing (Valhalla — chosen over OSRM for free-first + native toll-free
+ costing), tiles (OpenFreeMap, replacing Maptiler), hosting on Cloudflare
+ Workers with static assets.
+- Privacy stance chosen: persistent storage only for what users opt in to
+ reports (later), opt-in anonymized telemetry for traffic weighting. Nothing
+ stored without explicit consent.
+- Region: KL metro first — local geocoding quality is a v1 correctness factor.
+- Design system "Telemetry Rams": colors (Signal Orange #EB8008, Functional Red
+ #ED3F1C, Olive Green #736B1E, Soft Beige #D9D2C6, Matte Black #1A1A1A),
+ Helvetica Neue/Inter, 8px grid, line icons, Rams motto. Mockups live in
+ `stitch_minimalist_route_navigator/` (HTML + Tailwind CDN, single-file).
+- Known quality risk: turning single-file mockups into a real frontend without
+ losing the Rams look; user explicitly flagged design fidelity as the most
+ likely sink for v1.
+
+## Constraints
+
+- **Platform**: Telegram bot + Telegram Mini App (WebApp API) — the webview flow
+ is the product; no independent mobile app in v1
+- **Hosting**: Cloudflare Workers with static assets (research finding: Cloudflare now directs all new projects to Workers, not Pages) — one wrangler project serves Mini App statics + `/telegram/webhook` + `/api/*`
+- **Free-first infra**: every service must be open-source or genuinely free-tier (verified stack: grammY, MapLibre GL, OpenFreeMap, Valhalla, Photon — all $0); no paid APIs. Only infrastructure cost candidate is a small VPS for Valhalla + Photon; Oracle Cloud Always Free is the zero-cost option with a reliability asterisk
+- **Privacy**: no location history / profiles stored without explicit opt-in;
+ opt-in only telemetry
+- **Region**: KL metro tuning for v1 (geocoding, POI search)
+- **Design**: must match Telemetry Rams design system; Rams values are
+ non-negotiable per DESIGN.md
+- **Ops appetite (implied)**: IDEAS.md lists RabbitMQ, Prometheus/Grafana, Neon,
+ Resend — treat as future/aspirational; v1 should prefer the minimal set of
+ services that the core value actually needs (final choice deferred to research)
+
+## Key Decisions
+
+
+
+| Decision | Rationale | Outcome |
+|----------|-----------|---------|
+| Routing engine = **Valhalla** (replaces OSRM) | User wants free as much as possible; both are free OSS, but Valhalla natively supports toll-free routing (`use_tolls` costing), elevation, and localization — unlocks the KL commuter table-stakes feature | — Pending |
+| Free-first infra mandate | User priority: paid APIs are out; stack is all-OSS/free-tier; VPS (optionally Oracle Cloud Always Free) hosts Valhalla + Photon | — Pending |
+| Navigation-only v1 (no incident reports) | Prove the nav core before social mechanics; user walkthrough was pure navigation | — Pending |
+| Privacy = opt-in telemetry only | "Privacy-respecting" is the brand promise; storage follows explicit consent | — Pending |
+| KL metro first | Local landmark search quality depends on regional tuning | — Pending |
+| Telegram Mini App (not plain web link) | Needs user position, back button, tight Telegram context | — Pending |
+| Hybrid guidance: live map in-app + chat turn-cards when backgrounded | Battery + Telegram-context UX; matches walkthrough and mockups | — Pending |
+| Design fidelity guarded as top risk | Stated as most likely to sink v1; deserves early design-system phase | — Pending |
+
+## Evolution
+
+This document evolves at phase transitions and milestone boundaries.
+
+**After each phase transition** (via `/gsd-transition`):
+1. Requirements invalidated? → Move to Out of Scope with reason
+2. Requirements validated? → Move to Validated with phase reference
+3. New requirements emerged? → Add to Active
+4. Decisions to log? → Add to Key Decisions
+5. "What This Is" still accurate? → Update if drifted
+
+**After each milestone** (via `/gsd-complete-milestone`):
+1. Full review of all sections
+2. Core Value check — still the right priority?
+3. Audit Out of Scope — reasons still valid?
+4. Update Context with current state
+
+---
+*Last updated: 2026-10-23 — Phase 3 complete (Mini App Shell + Geolocation): Mini App live on the same Worker ([assets] binding), Rams map + location chain verified on Android/Desktop/browser after 6 in-gate debug rounds (telegram-web-app.js integration; HMAC signature-field erratum)*
diff --git a/.planning/REQUIREMENTS.md b/.planning/REQUIREMENTS.md
new file mode 100644
index 0000000..7917f87
--- /dev/null
+++ b/.planning/REQUIREMENTS.md
@@ -0,0 +1,132 @@
+# Requirements: araharah
+
+**Defined:** 2026-10-22
+**Core Value:** A user finds a local destination via Telegram and gets there with reliable turn-by-turn guidance — tracked by nothing beyond what they explicitly opted in to.
+
+## v1 Requirements
+
+Requirements for initial release. Each maps to roadmap phases.
+
+### Onboarding & Entry
+
+- [x] **ONBD-01**: User can open the araharah bot and start with /start — a concise explanation plus a always-available command menu (/help, /privacy)
+- [ ] **ONBD-02**: User can open the Mini App from a search result with their destination preloaded (no re-entry in the webview)
+- [ ] **ONBD-03**: User can add araharah to their phone home screen and launch search/navigation from there (addToHomeScreen + startapp deep-link)
+
+### Destination Search
+
+- [ ] **SRCH-01**: User can type a destination query (e.g. "KLCC") and receive a shortlist containing the right KL-metro landmark/POI
+- [ ] **SRCH-02**: User gets useful results despite abbreviations and typos (Jln↔Jalan, Tmn↔Taman, missing spaces, common Malay address shorthand)
+- [ ] **SRCH-03**: For a chosen destination, a native venue card is sent in chat, confirming where we're going before navigation opens
+
+### Current Location
+
+- [x] **GEO-01**: The Mini App can obtain the user's current location via Telegram LocationManager, with permission persisting across sessions
+- [x] **GEO-02**: When in-app location fails (denied permissions or Desktop Telegram without geolocation), the chat "send location" keyboard fallback obtains the origin
+- [x] **GEO-03**: The user's location is never written to persistent storage — only ephemeral TTL server state exists during the active trip
+
+### Routing & Route Choice
+
+- [ ] **RTNG-01**: User sees a calculated route from current location to destination drawn on a Rams-styled map (grayscale grounding, accent route)
+- [ ] **RTNG-02**: User can choose among up to 3 alternative routes presented as quiet Rams cards (time, distance, one-line difference)
+
+### Turn-by-Turn Guidance
+
+- [ ] **GUID-01**: While driving, the user sees a prominent next-maneuver banner (distance + arrow + street name) and a collapsible full maneuver list
+- [ ] **GUID-02**: After a missed turn, the app detects it and auto-recalculates from the current position without asking or blaming
+- [ ] **GUID-03**: The user sees ETA and remaining distance progress with each location fix (honest, self-paced ETA — no fake traffic confidence)
+- [ ] **GUID-04**: Guidance surfaces haptic cues at maneuver points (eyes-free, voice-free v1)
+- [ ] **GUID-05**: When the user minimizes the app mid-drive, the bot pushes chat turn-cards (map snapshot + next maneuver + ETA) for the remainder of the trip — best-effort projections
+- [ ] **GUID-06**: Re-opening the Mini App mid-trip resumes live guidance seamlessly at the user's progressed position
+
+### Platform Hygiene (Mini App)
+
+- [ ] **PX-01**: The Telegram BackButton walks app states (search → routes → guidance → back to search) and never dead-ends
+- [x] **PX-02**: The app follows Telegram light/dark themes for chrome while the map keeps the Rams cartography constant
+- [ ] **PX-03**: During guidance, safe areas are respected, vertical-swipe-close is disabled, and closing shows the "abandon route?" confirmation
+- [ ] **PX-04**: Guidance supports fullscreen and the screen remains usable in landscape (driving posture)
+
+### Design Fidelity & Privacy Promise
+
+- [x] **DSGN-01**: All screens render from a centralized Telemetry Rams token set (palette, type, spacing, radius-0) — no off-token values; map basemap cartography follows the same tokens
+- [x] **DSGN-02**: Each UI phase produces a mockup-vs-implementation gallery comparison reviewed for fidelity before proceeding
+- [x] **PRIV-01**: The privacy promise is visible in-product: /privacy command plus in-app trip start/end messaging ("trip ended, data deleted")
+- [x] **PRIV-02**: The backend holds no persistent per-user location history or profile — only TTL-expiring ephemeral state (privacy by construction, verifiable by architecture audit)
+
+## v2 Requirements
+
+Deferred to future release. Tracked but not in current roadmap.
+
+### Routing Extras
+
+- **RGNG-01**: Toll vs toll-free route toggle (Valhalla use_tolls costing — engine already supports it)
+- **RGNG-02**: Speed limit display where OSM maxspeed data exists ("show nothing rather than wrong values")
+- **GUID-07**: Voice guidance — spoken turns via Web Speech API, mute-by-default, sendVoice fallback (gated on iOS webview TTS spike)
+- **TRIP-01**: Live trip share via sendLocation(live_period) + editMessageLiveLocation with ETA — user-initiated, expires
+
+### Reports & Social (v2 cluster)
+
+- **REP-01**: User can report incidents (/accident, /traffic, /hazard) with location sharing
+- **REP-02**: Nearby users receive concise incident broadcasts
+- **OPTN-01**: Opt-in anonymized speed pings for aggregate traffic-weighted ETAs (seed of the v2 report crowd data)
+- **GAME-01**: Minimalist gamification (points for validated reports, Rams-badge achievements)
+
+## Out of Scope
+
+Explicitly excluded. Documented to prevent scope creep.
+
+| Feature | Reason |
+|---------|--------|
+| In-app ads / sponsored POI pins | Breaks the brand promise on a driving surface; privacy-violating profiling |
+| Engagement push notifications | Drive-away behavior for a privacy brand; only transactional messages ship |
+| Persistent accounts / contact requests | Telegram identity is the only identity; never requestContact() |
+| Offline maps product | Contrary to the Telegram-first premise; in-session prefetch only (v1.x candidate) |
+| "AI assistant" flavored onboarding | Deterministic commands are the model; Rams simplicity |
+| Group/location echo broadcasts | Crosses privacy boundaries of non-consenting users |
+| Ops-stack services (RabbitMQ, PostGrafana monitoring, Resend) | Stripped for v1 — no producer/user for them before reports exist |
+| Regional clusters / carpool groups | Unvalidated social hypothesis per PROJECT.md |
+
+## Traceability
+
+Which phases cover which requirements. Updated during roadmap creation.
+
+| Requirement | Phase | Status |
+|-------------|-------|--------|
+| DSGN-01 | Phase 1 | Complete |
+| DSGN-02 | Phase 1 | Complete |
+| ONBD-01 | Phase 2 | Complete |
+| PRIV-01 | Phase 2 | Complete |
+| PRIV-02 | Phase 2 | Complete |
+| GEO-01 | Phase 3 | Complete |
+| GEO-02 | Phase 3 | Complete |
+| GEO-03 | Phase 3 | Complete |
+| PX-02 | Phase 3 | Complete |
+| SRCH-01 | Phase 4 | Pending |
+| SRCH-02 | Phase 4 | Pending |
+| SRCH-03 | Phase 4 | Pending |
+| ONBD-02 | Phase 4 | Pending |
+| ONBD-03 | Phase 4 | Pending |
+| RTNG-01 | Phase 5 | Pending |
+| RTNG-02 | Phase 5 | Pending |
+| GUID-01 | Phase 6 | Pending |
+| GUID-02 | Phase 6 | Pending |
+| GUID-03 | Phase 6 | Pending |
+| GUID-04 | Phase 6 | Pending |
+| GUID-05 | Phase 6 | Pending |
+| GUID-06 | Phase 6 | Pending |
+| PX-01 | Phase 6 | Pending |
+| PX-03 | Phase 6 | Pending |
+| PX-04 | Phase 7 | Pending |
+
+**Coverage:**
+- v1 requirements: 22 total
+- Mapped to phases: 22 ✓
+- Unmapped: 0
+
+Staging notes (each requirement maps to exactly one owning phase):
+- PRIV-01: /privacy command delivered in Phase 2; the in-app "trip ended, data deleted" messaging completes at the trip boundary in Phase 6 (success criterion there).
+- PX-01 (BackButton full state walk) and PX-03 (safe areas, swipe-close, abandon confirmation): shell grounding built in Phase 3; observable behaviors owned by Phase 6 where the full state graph exists.
+
+---
+*Requirements defined: 2026-10-22*
+*Last updated: 2026-10-22 — roadmap created, traceability filled (22/22 mapped)*
diff --git a/.planning/STATE.md b/.planning/STATE.md
new file mode 100644
index 0000000..15af468
--- /dev/null
+++ b/.planning/STATE.md
@@ -0,0 +1,84 @@
+---
+gsd_state_version: 1.0
+milestone: v1.0
+milestone_name: milestone
+status: planning
+stopped_at: Phase 4 context gathered
+last_updated: "2026-10-23T14:33:06.676Z"
+last_activity: 2026-10-23
+progress:
+ total_phases: 7
+ completed_phases: 3
+ total_plans: 11
+ completed_plans: 11
+ percent: 100
+---
+
+# Project State
+
+## Project Reference
+
+See: .planning/PROJECT.md (updated 2026-10-22)
+
+**Core value:** A user finds a local destination via Telegram and gets there with reliable turn-by-turn guidance — tracked by nothing beyond what they explicitly opted in to.
+**Current focus:** Phase 03 — mini-app-shell-geolocation
+
+## Current Position
+
+Phase: 4
+Plan: Not started
+Status: Ready to plan
+Last activity: 2026-10-23
+
+Progress: [░░░░░░░░░░] 0%
+
+## Performance Metrics
+
+**Velocity:**
+
+- Total plans completed: 11
+- Average duration: —
+- Total execution time: —
+
+**By Phase:**
+
+| Phase | Plans | Total | Avg/Plan |
+|-------|-------|-------|----------|
+| — | — | — | — |
+| 01 | 4 | - | - |
+| 02 | 3 | - | - |
+| 3 | 4 | - | - |
+
+## Accumulated Context
+
+### Decisions
+
+Logged in PROJECT.md Key Decisions table. Affecting current work:
+
+- Routing = Valhalla (toll-free stays v2); self-hosted Photon for lookup; OpenFreeMap tiles + MapLibre GL JS v6
+- grammY on one Cloudflare Worker (static assets + webhook + /api/*) — Workers, not Pages
+- KV-only storage, TTL-only (chat_sessions minutes / guidance_sessions ~2h); guidance_sessions envelope designed in Phase 5 (upstream pre-work for GUID-05/06)
+
+### Pending Todos
+
+None yet.
+
+### Blockers/Concerns
+
+- Design-source conflict (root `DESIGN.md` typography vs `telemetry_rams/DESIGN.md` + mockups) — Phase 1 resolves; mockup markup is layout reference only, design doc tokens are binding (scrub rounded/blur utilities)
+- VPS provisioning starts in Phase 4 (Photon bake-off); free-first mandate: Valhalla + Photon on one VPS, Oracle Cloud Always Free candidate
+- Research-flagged phases needing `/gsd-research-phase`: 3 (geolocation client matrix), 4 (Photon KL bake-off + gazetteer), 6 (backgrounded turn-card framing + snapshots)
+- Project skill `.agents/skills/design-taste-frontend/` applies to UI phases (existing-design-system special case; Rams tokens binding)
+
+## Deferred Items
+
+| Category | Item | Status | Deferred At |
+|----------|------|--------|-------------|
+| v1.x features | Voice guidance, live trip share, toll toggle, speed limits | Deferred to v1.x milestone (after user validation) | Roadmap init 2026-10-22 |
+| v2 | Incident reports, gamification, traffic-weighted ETA | Out of scope for v1 | Requirements defined 2026-10-22 |
+
+## Session Continuity
+
+Last session: 2026-10-23T14:33:06.657Z
+Stopped at: Phase 4 context gathered
+Resume file: .planning/phases/04-destination-search-kl-gazetteer/04-CONTEXT.md
diff --git a/.planning/config.json b/.planning/config.json
new file mode 100644
index 0000000..ad2f915
--- /dev/null
+++ b/.planning/config.json
@@ -0,0 +1,69 @@
+{
+ "model_profile": "inherit",
+ "commit_docs": false,
+ "parallelization": true,
+ "search_gitignored": false,
+ "brave_search": false,
+ "firecrawl": false,
+ "exa_search": false,
+ "git": {
+ "branching_strategy": "none",
+ "phase_branch_template": "gsd/phase-{phase}-{slug}",
+ "milestone_branch_template": "gsd/{milestone}-{slug}",
+ "quick_branch_template": null
+ },
+ "workflow": {
+ "research": true,
+ "plan_check": true,
+ "verifier": true,
+ "nyquist_validation": true,
+ "auto_advance": false,
+ "node_repair": true,
+ "node_repair_budget": 2,
+ "ui_phase": true,
+ "ui_safety_gate": true,
+ "text_mode": false,
+ "research_before_questions": false,
+ "discuss_mode": "discuss",
+ "skip_discuss": false,
+ "code_review": true,
+ "code_review_depth": "standard"
+ },
+ "hooks": {
+ "context_warnings": true
+ },
+ "project_code": null,
+ "phase_naming": "sequential",
+ "agent_skills": {},
+ "features": {},
+ "mode": "yolo",
+ "granularity": "coarse",
+ "model_overrides": {
+ "gsd-planner": "opencode-go/minimax-m3",
+ "gsd-roadmapper": "opencode-go/minimax-m3",
+ "gsd-phase-researcher": "opencode-go/minimax-m3",
+ "gsd-project-researcher": "opencode-go/minimax-m3",
+ "gsd-research-synthesizer": "opencode-go/minimax-m3",
+ "gsd-codebase-mapper": "opencode-go/minimax-m3",
+ "gsd-ui-researcher": "opencode-go/minimax-m3",
+ "gsd-doc-writer": "opencode-go/minimax-m3",
+ "gsd-executor": "opencode-go/glm-5.2",
+ "gsd-debugger": "opencode-go/glm-5.2",
+ "gsd-verifier": "opencode-go/glm-5.2",
+ "gsd-plan-checker": "opencode-go/glm-5.2",
+ "gsd-integration-checker": "opencode-go/glm-5.2",
+ "gsd-nyquist-auditor": "opencode-go/glm-5.2",
+ "gsd-pattern-mapper": "opencode-go/glm-5.2",
+ "gsd-ui-checker": "opencode-go/glm-5.2",
+ "gsd-ui-auditor": "opencode-go/glm-5.2",
+ "gsd-doc-verifier": "opencode-go/glm-5.2"
+ },
+ "branching_strategy": "none",
+ "quick_branch_template": null,
+ "intel": {
+ "enabled": false
+ },
+ "graphify": {
+ "enabled": false
+ }
+}
diff --git a/.planning/phases/01-design-tokens-rams-foundation/01-CONTEXT.md b/.planning/phases/01-design-tokens-rams-foundation/01-CONTEXT.md
new file mode 100644
index 0000000..d418908
--- /dev/null
+++ b/.planning/phases/01-design-tokens-rams-foundation/01-CONTEXT.md
@@ -0,0 +1,117 @@
+# Phase 1: Design Tokens + Rams Foundation - Context
+
+**Gathered:** 2026-10-22
+**Status:** Ready for planning
+
+
+## Phase Boundary
+
+Distill the Telemetry Rams design system into a single centralized, importable token package — including a Rams-styled MapLibre basemap derived from OpenFreeMap `liberty` — proven with a rendered component sheet and a user-approved mockup-vs-implementation side-by-side gallery, before any product screen is built. Ends the inline-Tailwind-duplication and design-source conflict. Bot, Mini App shell, search, and routing are separate phases; this phase builds no product screens.
+
+
+
+
+## Implementation Decisions
+
+### Token package format
+- **D-01:** Tailwind v4 CSS-first package: tokens declared as CSS custom properties plus the `@theme` directive. No v3-style JS config. CSS variables double as the consumption path for MapLibre basemap styles and later phases.
+- **D-02:** Token naming mirrors the mockup vocabulary verbatim (~40-token Material-style scale: `signal-orange`, `matte-black`, `space-md`, `display-lg`, ...) so mockup-derived markup maps 1:1 and fidelity comparison stays direct.
+- **D-03:** The token package ships together with a rendered component sheet that consumes it — the sheet itself proves token completeness (DSGN-01's "every rendered component derives from tokens" is demonstrated, not asserted).
+- **D-04:** Pre-slot the dual Telegram light/dark theme boundary now (themeable shell surfaces as token slots), while map cartography stays Rams-constant. Phase 3 (PX-02) then maps two background values onto slots instead of restructuring tokens.
+
+### Source-of-truth resolution
+- **D-05:** Root `DESIGN.md` is superseded for specifics: keep it as Rams philosophy prose with a header banner pointing to the token package. `telemetry_rams/DESIGN.md` content (the mockup-matching system) flows into the package; the losing typography claim (Helvetica/Inter + 5 colors) is explicitly marked superseded.
+- **D-06:** The four mockup `code.html` files stay untouched, read-only generated reference artifacts (fine as-is per CONCERNS.md). Their inline `tailwind.config` blocks are superseded but NOT edited. Fidelity comparison targets rendered output, not markup.
+- **D-07:** The token package owns all numeric values (hex, px, rgba, font sizes). Design docs describe principles and link to the package; docs never restate hex codes or dimensions — the drift that caused the conflict cannot recur.
+
+### Basemap styling depth
+- **D-08:** Full Rams pass on the OpenFreeMap `liberty` fork in this phase: grayscale/muted land + water grounding, desaturated roads, muted label styling; `signal-orange` accent reserved exclusively for the route line (rendered in Phase 5). Orange remains navigation-only semantics.
+- **D-09:** Basemap colors are derived from the token package (values read/synced from the package), not hand-tuned in the style JSON — honoring DSGN-01's "map cartography follows the same tokens". Exact derivation mechanism (generation script vs synced values file) is planner/researcher's choice.
+- **D-10:** Map labels adopt Rams typography (Space Grotesk / Geist), coherent with the HUD, using the same font assets.
+
+### Component sheet + gallery
+- **D-11:** Component sheet scope is mockup-derived: everything the 4 mockup screens actually show — buttons, cards, chips/badges, the maneuver banner, inputs, and a typography specimen. No invention of unseen components.
+- **D-12:** The approval gallery is a static side-by-side HTML page: rendered implementation components against the four mockup screens' renders, token-by-token. This page is the DSGN-02 mechanism template — every later UI phase extends the same pattern.
+- **D-13:** DSGN-01's audit sweep is automated: a script that scans rendered CSS/classes and flags any color/spacing/typography/radius value not in the token set. "Zero off-token values" is evidence, not eyeball claim.
+- **D-14:** Gallery renders are produced with Playwright real-browser screenshots (script committed for reuse by later UI phases under the design-taste-frontend skill's real-browser inspection rule).
+
+### Build pipeline
+- **D-15:** Minimal `package.json` + Tailwind v4 CLI and npm scripts; design gallery/sheet + basemap style packaged in one design folder consumed by later phases. No app framework scaffold — the Mini App shell shape is decided in Phase 3.
+- **D-16:** Self-host Geist + Space Grotesk woff2 in the repo as part of the token package; Google Fonts CDN links are removed going forward (may remain in mockups).
+- **D-17:** Local npm scripts only (build / gallery / audit) in Phase 1; CI workflows come with Phase 2's deploy pipeline. Keep tooling minimal.
+
+### OpenCode's Discretion
+- Exact derivation/refresh mechanism basemap ← tokens (script vs synced file)
+- Gallery page file layout within the design folder
+- Audit script implementation details and threshold handling
+- Icon strategy details within the sheet (Material Symbols vs inline SVG usage observed in mockups)
+
+
+
+
+## Specific Ideas
+
+- "Map cartography stays Rams-constant" — the map background must look identical under Telegram light and dark themes; only shell surfaces re-theme.
+- Mockup screens are the spec — the gallery side-by-side against their rendered output is the fidelity bar.
+- Scope creep avoided by design: motion/animation polish, incident components (reporter screen's modal flow) render only as static sheet entries where the mockups show them; full interactive flows belong to later phases.
+
+
+
+
+## Canonical References
+
+**Downstream agents MUST read these before planning or implementing.**
+
+### Design system (binding)
+- `stitch_minimalist_route_navigator/telemetry_rams/DESIGN.md` — canonical token vocabulary, component rules, zero-radius / no-blur constraints
+- `DESIGN.md` — Rams philosophy (kept for principles; its typography/palette specifics are superseded per D-05)
+- `stitch_minimalist_route_navigator/active_route_guidance/code.html` — markup/layout reference (read-only) for maneuver banner and incident telemetry band
+- `stitch_minimalist_route_navigator/navigate_map_overview/code.html` — layout reference for map screen + layer controls
+- `stitch_minimalist_route_navigator/community_incident_reporter/code.html` — layout reference for incident card components
+- `stitch_minimalist_route_navigator/telegram_live_traffic_feed/code.html` — layout reference for chip/badge components
+
+### Planning inputs
+- `.planning/ROADMAP.md` §Phase 1 — goal, success criteria, notes (repo hygiene, skill usage, no research phase)
+- `.planning/REQUIREMENTS.md` — DSGN-01, DSGN-02 (this phase's requirements)
+- `.planning/codebase/CONCERNS.md` — Zone.Identifier debt, design-source conflict, mockup fragility (Phase 1 resolves these)
+- `.planning/codebase/STACK.md` — token duplication, Play-CDN caveat, design constraints
+- `.agents/skills/design-taste-frontend/SKILL.md` — applies in existing-brand special case: keep Rams palette/type/dashboard house rules; real-browser inspection for the gallery artifact
+
+
+
+
+## Existing Code Insights
+
+### Reusable Assets
+- Inline `tailwind.config` block in each `code.html` — byte-identical token source to extract into the v4 package (matches `telemetry_rams/DESIGN.md:2-148`)
+- Mockup `/code.html` + `/screen.png` pairs — ready-made inputs for the side-by-side gallery
+- SVG patterns in mockups (route vectors, `stroke-linecap="square"`, 2px strokes) — reuse for sheet icon components
+
+### Established Patterns
+- kebab-case token/class names (`bg-matte-black`, `text-signal-orange`) — carry into the v4 package
+- 8px baseline grid, 4px/8px multiples for every dimension
+- Uppercase labels with expanded tracking; Material Symbols Outlined iconography
+- Color semantics: orange = navigation/primary, red = hazards/errors, green = police-only (per mockups); keep strict
+
+### Integration Points
+- The token package is consumed by: the rendered sheet + gallery (Phase 1), the Mini App shell (Phase 3), every UI phase's Tailwind build
+- The basemap style JSON becomes the MapLibre style loaded by Phase 3/5/6's map views
+- Playwright capture script becomes the DSGN-02 gallery evidence toolchain for all UI phases
+
+
+
+
+## Deferred Ideas
+
+- Interactive mockup/implementation compare tool — a static page suffices for v1; revisit if gallery review becomes painful
+- Full incident-report interaction flows — Phase 1 renders reporter-screen components statically only; the privacy boundaries of incident reporting ship post-v1 (v2 cluster REP-01/02, per REQUIREMENTS.md)
+- CI workflow for gallery + audit — deferred to Phase 2 deploy pipeline (D-17)
+
+None — discussion stayed within phase scope.
+
+
+
+---
+
+*Phase: 01-design-tokens-rams-foundation*
+*Context gathered: 2026-10-22*
diff --git a/.planning/phases/01-design-tokens-rams-foundation/01-DISCUSSION-LOG.md b/.planning/phases/01-design-tokens-rams-foundation/01-DISCUSSION-LOG.md
new file mode 100644
index 0000000..ef8de27
--- /dev/null
+++ b/.planning/phases/01-design-tokens-rams-foundation/01-DISCUSSION-LOG.md
@@ -0,0 +1,57 @@
+# Phase 1: Design Tokens + Rams Foundation - Discussion Log
+
+> **Audit trail only.** Do not use as input to planning, research, or execution agents.
+> Decisions captured in CONTEXT.md — this log preserves the analysis.
+
+**Date:** 2026-10-22
+**Phase:** 01-design-tokens-rams-foundation
+**Mode:** default (interactive)
+
+## Areas Discussed
+
+### Token package format
+| Question | Options presented | Selection |
+|---|---|---|
+| Package form | Tailwind v4 @theme / v3 JS config / plain CSS vars + adapter | Tailwind v4 CSS-first (@theme) |
+| Naming | Mirror mockup names / semantic rename / you decide | Mirror mockup names |
+| Bare tokens vs tokens + rendered sheet | Tokens + sheet / tokens only / you decide | Tokens + rendered sheet |
+| Telegram light/dark pre-planning | Pre-slot dual theme / defer to Phase 3 / you decide | Pre-slot dual theme |
+
+### Source-of-truth resolution
+| Question | Options presented | Selection |
+|---|---|---|
+| Root DESIGN.md conflict | Supersede + keep philosophy / merge + rewrite / archive | Supersede, keep philosophy |
+| Fate of mockups + inline Tailwind blocks | Stay read-only / migrate mockups / you decide | Mockups stay read-only |
+| Owner of numeric values | Package owns / docs own / you decide | Package owns values |
+
+### Basemap styling depth
+| Question | Options presented | Selection |
+|---|---|---|
+| Rams-ification depth | Full Rams pass / grayscale only / iterate in gallery | Full Rams pass |
+| Color sourcing | Derived from tokens / fork + hand-tune / you decide | Derived from tokens |
+| Label typography | Rams fonts / stock / compare in gallery | Rams fonts in labels |
+
+### Component sheet + gallery scope
+| Question | Options presented | Selection |
+|---|---|---|
+| Sheet components | Mockup-derived set / core primitives / you decide | Mockup-derived set |
+| Gallery form | Static side-by-side / interactive tool / you decide | Static side-by-side page |
+| DSGN-01 audit | Automated / manual / you decide | Automated token audit |
+| Render production | Playwright / manual captures / you decide | Playwright screenshots |
+
+### Build pipeline choice
+| Question | Options presented | Selection |
+|---|---|---|
+| Phase 1 tooling | Minimal npm + v4 CLI / full framework / CSS only | Minimal npm + v4 CLI |
+| Fonts | Self-host woff2 / keep Google Fonts CDN / you decide | Self-host fonts |
+| CI scope | Local scripts / CI day one / you decide | Local scripts first |
+
+## Corrections Made
+
+No corrections — user accepted recommended options throughout.
+
+## Deferred Ideas
+
+- Interactive compare tool (revisit if gallery review painful)
+- Incident-report interaction flows (v2: REP-01/02)
+- CI workflow (Phase 2)
diff --git a/.planning/phases/01-design-tokens-rams-foundation/01-REVIEW-FIX.md b/.planning/phases/01-design-tokens-rams-foundation/01-REVIEW-FIX.md
new file mode 100644
index 0000000..3a1297a
--- /dev/null
+++ b/.planning/phases/01-design-tokens-rams-foundation/01-REVIEW-FIX.md
@@ -0,0 +1,149 @@
+---
+phase: 01-design-tokens-rams-foundation
+fixed_at: 2026-10-22T02:05:00Z
+review_path: .planning/phases/01-design-tokens-rams-foundation/01-REVIEW.md
+iteration: 1
+findings_in_scope: 6
+fixed: 6
+skipped: 0
+status: all_fixed
+---
+
+# Phase 1: Code Review Fix Report
+
+**Fixed at:** 2026-10-22T02:05:00Z
+**Source review:** `.planning/phases/01-design-tokens-rams-foundation/01-REVIEW.md`
+**Iteration:** 1
+
+**Summary:**
+- Findings in scope: 6 (BLOCKER CR-01 + WR-01..WR-05; INFO findings out of scope per orchestrator)
+- Fixed: 6
+- Skipped: 0
+
+**Note on provenance:** a prior fixer run applied all six fixes as atomic commits (61d15c2…43b7d99)
+but terminated before writing this report. This pass *independently re-verified* each committed fix
+(diffs audited against the review's prescribed mechanism, all four verification gates re-run
+empirically, see Verification section) — no fixes were re-applied; master was already clean.
+
+## Fixed Issues
+
+### CR-01: Dead `--radius-DEFAULT` token — bare `rounded` compiles to `0.25rem` (BLOCKER)
+
+**Commit:** `61d15c2`
+**Files modified:** `theme/src/tokens.css`, `theme/scripts/audit.mjs`, `theme/scripts/audit.test.mjs`
+**Applied fix:** Replaced the inert v3-style `--radius-DEFAULT: 0px` with the deprecated bare
+`--radius: 0px` — the key Tailwind v4.3.3 actually drives the bare `rounded` utility from
+(`node_modules/tailwindcss/theme.css` "Deprecated" block). Block comment corrected (bare
+`rounded` is now var-driven; `rounded-none`/`rounded-full` are literal built-ins; the DEFAULT
+sub-key must not reappear). Audit radius drift guard extended to `/--radius(?:-[A-Za-z0-9-]*)?…/`
+so a future non-zero bare key trips exit 2. Regression tests T9 added: bare-key presence,
+inert-DEFAULT absence, non-zero bare key → exit 2, and a controlled build asserting
+`.rounded` compiles to `var(--radius)` with `--radius: 0px` emitted by `@theme static`.
+
+### WR-01: Prose candidate leakage compiles Rams-forbidden `.shadow`/`.blur` into the shipped stylesheet
+
+**Commit:** `689bc9f`
+**Files modified:** `theme/src/theme.css`, `theme/src/tokens.css`, `theme/scripts/audit.test.mjs`
+**Applied fix:** (1) `@source` narrowed from the whole mockup tree to the four mockup markup
+files, plus `@source not "../scripts"` / `@source not "../basemap"` so toolchain prose can never
+mint utility classes. (2) Defense in depth at the key: the deprecated bare `--shadow`,
+`--shadow-inner`, `--drop-shadow`, `--blur` namespaces are set to `initial` in `@theme static`,
+so bare shadow/blur utilities can never compile from any candidate source (scale keys remain —
+mockup-worn `shadow-*` classes still compile and stay under adjudication 1). T5 regression
+checks added: no `.shadow{` / `.blur{` in `dist/rams.css`, no `border-radius:.25rem` anywhere
+in dist.
+
+### WR-02: `familiesFromShorthand` logic bug — off-token families in `font:` shorthands pass unflagged
+
+**Commit:** `5e95741`
+**Files modified:** `theme/scripts/audit.mjs`, `theme/scripts/audit.test.mjs`
+**Applied fix:** Two-layers, contract-true: (1) any literal (non-`var()`) `font:` shorthand is
+flagged outright with the convention message (`font: var(--type-*)`), naming the extracted
+families; `var()` shorthands pass. (2) `familiesFromShorthand` itself repaired — iteratively
+strips the leading style/variant/weight keyword run (any CSS-grammar order, incl. oblique
+angles), then the size[/line-height] component (incl. `calc()`), per comma group, so the family
+is genuinely extracted for the message instead of being silently dropped by the old
+single-strip + numeric-filter bug. T10 regression tests: literal shorthand → exit 1, Arial
+extracted, sanctioned-family shorthand still flagged, `var()` shorthand passes.
+
+### WR-03: Color-evasion gaps — named keywords, modern color functions, `text-shadow` all pass
+
+**Commit:** `ee48e76`
+**Files modified:** `theme/scripts/audit.mjs`, `theme/scripts/audit.test.mjs`
+**Applied fix:** (1) Full CSS Color keyword list (`NAMED_COLORS`, ~148 entries) flagged on the
+color-bearing property set (`COLORISH_PROPS` incl. shorthands, fill/stroke, borders,
+text-decoration, accent-color); `currentcolor`/`transparent` and css-wide keywords stay
+sanctioned. (2) Modern color functions — `oklch()/oklab()/lab()/lch()/hwb()/color()/color-mix()`
+— flagged on any property unless the value is `var()`-bearing (Tailwind's own `color-mix`
+emissions are var-driven and pass). (3) `text-shadow` added to `shadowViolations`: any
+non-none value forbidden (Rams Shapes). T11 regression tests: `color: red`, `oklch(...)`,
+`color(display-p3 ...)`, non-hex `text-shadow` all exit 1 and are listed; sanctioned keywords
+pass. Audit header (§5) updated to document the extended surface.
+
+### WR-04: D-13 contract shortfall — spacing and typography numerics not audited
+
+**Commit:** `5e254cb`
+**Files modified:** `theme/scripts/audit.mjs`, `theme/gallery.html`
+**Applied fix:** Implemented the missing checks rather than amending the claim. (1) Spacing:
+`padding`/`margin`/`gap` families (logical variants included) — literals must equal a
+`--spacing-*` token value, be zero, or `auto`; dimensional geometry (width/height, offsets,
+border widths, the 18×18 checkbox) is spec-per-components-header and out of scope
+(adjudication 6). (2) Typography: `font-size`/`font-weight`/`line-height`/`letter-spacing`
+validated against the `--type-*` scale, parsed from the compound token grammar (`weight
+size/line-height family…` + `-tracking` companions); `normal`/`bold` keyword equivalents
+sanctioned; `@font-face` blocks exempt from numeric scale as asset metadata (D-16) but still
+color/family-checked (adjudication 7). `npm run audit` output now reports
+"6 spacing values / 10 type sizes / 9 trackings". Gallery D-13 evidence wording updated to
+"colors, spacing, typography, radius, shadows, blur". T12 regression tests: off-scale
+numerics (`padding: 13px`, `font-size: 17px`, weight 650, tracking) → exit 1 and listed;
+token-value literals (1rem, 0.25rem, 14px, 700, 20px, -0.04em), zero/auto, and font-face
+metadata pass.
+
+### WR-05: Static server path guard uses prefix match without separator
+
+**Commit:** `43b7d99`
+**Files modified:** `theme/scripts/screenshot.mjs`
+**Applied fix:** `sep` imported from `node:path`; guard changed to
+`if (target !== THEME && !target.startsWith(THEME + sep))` — the docroot prefix must end at a
+path separator, so a decoded `..%2FthemeX` (which joins to a `themeX`-sibling path passing the
+old bare `startsWith(THEME)`) now 403s.
+
+## Skipped Issues
+
+None — all six in-scope findings fixed and verified.
+
+## Verification (all four gates, empirically re-run in this pass)
+
+1. **Build radius gate** — `npm run build --workspace theme`; `grep -o 'border-radius:[^;}]*' theme/dist/rams.css | sort -u` →
+ `border-radius:0`, `border-radius:var(--radius)`, `border-radius:3.40282e38px`
+ (the sanctioned `rounded-full` ≥1e30px literal clamp). **No `.25rem` / non-token radius forms.**
+ `.rounded` compiles to `border-radius:var(--radius)` with `--radius:0px` emitted by `@theme static`. ✅
+2. **Audit gate** — `npm run audit` (repo root) →
+ `AUDIT CLEAN — 354 declarations checked … — 0 off-token values. token sheet: 55 colors / 9 radius
+ tokens (all zero) / 7 sanctioned font families / 6 spacing values / 10 type sizes / 9 trackings`. ✅
+3. **Test suite** — `node theme/scripts/audit.test.mjs` → **38 passed, 0 failed**
+ (was 16/16 pre-fix; now includes T9 CR-01, T10 WR-02, T11 WR-03, T12 WR-04, extended T5 WR-01). ✅
+4. **Screenshot server guard (live probe)** — server started under the real capture run
+ (`node theme/scripts/screenshot.mjs`, listener located by PID), probed with `curl --path-as-is`:
+ - `/..%2FthemeX/secret` → **403 forbidden** (the exact WR-05 exploit shape: a `themeX` sibling
+ that the old bare prefix check passed)
+ - `/..%2FthemeX/gallery.html` → **403**
+ - `/..%2Fstitch_minimalist_route_navigator/…` → **403**
+ - `/src/sheet.html` → 200 (correct content — no over-blocking)
+ - `/gallery.html` → 200 — ✅
+ (Method note: a first probe port grab had hit Chromium's own devtools listener — PID-matched
+ listener selection fixed that; the guard result above is from the real capture server.)
+ Bonus: full capture run completed `CAPTURE OK — 6/6` non-blank captures.
+
+Also verified byte-for-byte: deep diff audit of all six fix commits against the REVIEW.md
+prescribed fix shapes (`git show 61d15c2 689bc9f 5e95741 ee48e76 5e254cb 43b7d99`) — mechanisms
+are real (key-level token disarmament, regex/grammar repair, parser extension, guard change),
+not cosmetic suppressions; per CR-01 guidance the elimination of the banned utility is the
+intended goal, so post-fix compiled output genuinely lacks `.rounded`/`.shadow`/`.blur`
+non-zero/off-token forms.
+
+---
+
+_Fixed: 2026-10-22T02:05:00Z_
+_Fixer: OpenCode (gsd-code-fixer)_
+_Iteration: 1_
diff --git a/.planning/phases/01-design-tokens-rams-foundation/01-REVIEW.md b/.planning/phases/01-design-tokens-rams-foundation/01-REVIEW.md
new file mode 100644
index 0000000..b522b8b
--- /dev/null
+++ b/.planning/phases/01-design-tokens-rams-foundation/01-REVIEW.md
@@ -0,0 +1,312 @@
+---
+phase: 01-design-tokens-rams-foundation
+reviewed: 2026-10-22T01:08:55Z
+depth: standard
+files_reviewed: 13
+files_reviewed_list:
+ - package.json
+ - theme/package.json
+ - theme/src/tokens.css
+ - theme/src/theme.css
+ - theme/src/components.css
+ - theme/src/sheet.html
+ - theme/basemap/build.mjs
+ - theme/basemap/liberty-rams.json
+ - theme/basemap/index.html
+ - theme/scripts/audit.mjs
+ - theme/scripts/audit.test.mjs
+ - theme/scripts/screenshot.mjs
+ - theme/gallery.html
+findings:
+ critical: 1
+ warning: 5
+ info: 7
+ total: 13
+status: issues_found
+---
+
+# Phase 1: Code Review Report
+
+**Reviewed:** 2026-10-22T01:08:55Z
+**Depth:** standard (with empirical verification: controlled Tailwind builds, live audit runs, test-suite execution, machine verification of the 6,089-line generated `liberty-rams.json`)
+**Files Reviewed:** 13
+**Status:** issues_found
+
+## Summary
+
+The phase delivers a genuinely well-engineered token package: `npm run audit` exits 0 on the
+real build (354 declarations checked), the TDD suite passes 16/16, all 7 self-hosted woff2
+files exist and are wired correctly (no CDN fonts), the compiled `dist/rams.css` emits the
+`--color-*`/`--spacing-*`/`--type-*` tokens plus the D-04 shell slots with the dark-scheme
+media query, and `liberty-rams.json` was machine-verified clean (no accent hex/rgb forms, no
+`line-gradient`, no `icon-color`, no non-zero halo blur, only opacity ramps preserved,
+font substitutions honestly recorded in `metadata`).
+
+However, the adversarial pass found one blocker in the foundation deliverable itself, plus a
+cluster of audit-gate holes proven by end-to-end evasion tests:
+
+1. **BLOCKER (CR-01):** `--radius-DEFAULT: 0px` is a dead token in Tailwind v4.3.3 — the bare
+ `rounded` utility is driven by the deprecated `--radius` key, not `--radius-DEFAULT`. The
+ shipped `theme/dist/rams.css` therefore contains `.rounded{border-radius:.25rem}` — a
+ non-zero radius value owned by **no token**, violating the binding zero-roundness
+ constraint, D-07 ("the token package owns all numeric values"), and the tokens.css comment
+ that asserts "every token-driven rounded-* utility compiles to 0".
+2. **Vocabulary leakage (WR-01):** `.shadow{…}` and `.blur{…}` (Rams-forbidden constructs,
+ with off-token values `#0000001a` / `blur(8px)` baked in) compile into the shipped
+ stylesheet from **prose**, not from any mockup class attribute — so audit adjudication 1's
+ rationale ("utilities the mockups wear") does not even cover them.
+3. **Audit-gate holes (WR-02/03/04):** proven evasions — `font: 700 14px/18px Arial` passes
+ unflagged (logic bug in `familiesFromShorthand`), as do `color: red`, `oklch(...)`,
+ `color(display-p3 …)`, non-hex `text-shadow`, and all spacing/type-scale numerics
+ (`padding: 13px`, `font-size: 17px`), contradicting the tool's own coverage claims and
+ D-13's stated contract.
+
+None of these are caught by the current green gates, which is exactly why they need fixing
+before later phases consume the package.
+
+## Critical Issues
+
+### CR-01: Dead `--radius-DEFAULT` token — bare `rounded` compiles to `0.25rem`, violating the binding zero-roundness constraint (BLOCKER)
+
+**File:** `theme/src/tokens.css:98-109` (token), `theme/src/theme.css:13-15` (build entry); evidence in compiled `theme/dist/rams.css` (minified, line 2): `.rounded{border-radius:.25rem}`
+**Issue:** The radius block sets `--radius-DEFAULT: 0px` intending to zero the bare `rounded`
+utility (v3-style `DEFAULT` key), but Tailwind v4.3.3 does **not** read `--radius-DEFAULT` for
+that utility — the bare `rounded` is driven by the deprecated `--radius` key (see
+`node_modules/tailwindcss/theme.css:508`, `@theme default inline reference { --radius: 0.25rem; }`).
+Empirically verified with controlled builds against the repo's own Tailwind 4.3.3:
+
+- `@theme { --radius-DEFAULT: 0px; }` → `.rounded { border-radius: 0.25rem; }` (token ignored)
+- `@theme { --radius: 0px; }` → `.rounded { border-radius: var(--radius); }` (correct override)
+
+Consequences:
+- The shipped `dist/rams.css` contains `.rounded{border-radius:.25rem}` — a numeric value
+ owned by no token, in the phase deliverable whose thesis is D-07 ("the token package owns
+ all numeric values") and the "zero off-token values" evidence claim.
+- The tokens.css comment (lines 98-101) is factually wrong: it carves out only
+ `rounded-none`/`rounded-full` as literal built-ins and asserts every other token-driven
+ `rounded-*` compiles to 0. Bare `rounded` compiles to `0.25rem` with the current token set.
+- Any later phase wearing `rounded` (a Tailwind-default habit; the class *is* in the compiled
+ vocabulary) silently gets 4px corners. The audit does flag it once worn (verified with a
+ fixture page: `theme/dist/rams.css:2: .rounded { border-radius: .25rem } — non-zero
+ border-radius`), but the package is supposed to prevent, not be caught by, its own gate.
+- The audit's radius drift-guard (`theme/scripts/audit.mjs:173`,
+ `/--radius-[A-Za-z0-9-]*\s*:\s*(…)/`) only matches `--radius-*` with a dash, so a future
+ bare `--radius` override escapes the drift guard entirely; `radiusAllowed()`
+ (`audit.mjs:409`) also passes any `var()` unconditionally.
+
+**Fix:**
+```css
+/* theme/src/tokens.css — @theme static radius block */
+@theme static {
+ --radius: 0px; /* v4's deprecated bare key — THE key that drives the bare `rounded` utility */
+ --radius-xs: 0px; /* …rest unchanged… */
+}
+```
+Delete `--radius-DEFAULT: 0px;` (inert in v4) or keep it only with a comment marking it
+inert; correct the block comment to state that `rounded`/`rounded-none`/`rounded-full` are
+literal built-ins and that `--radius` must be overridden explicitly. Rebuild and confirm
+`.rounded{border-radius:0px}` in `dist/rams.css`. Extend the audit drift-guard:
+
+```js
+/* audit.mjs — cover the deprecated bare key too */
+for (const m of text.matchAll(/--radius(?:-[A-Za-z0-9-]*)?\s*:\s*([^;}]+?)\s*;/g)) { … }
+```
+
+## Warnings
+
+### WR-01: Prose candidate leakage compiles Rams-forbidden `.shadow`/`.blur` utilities into the shipped stylesheet
+
+**File:** `theme/src/theme.css:24` (`@source "../../stitch_minimalist_route_navigator"`), plus the CLI's implicit auto-scan of `theme/`
+**Issue:** `@source` points at the whole mockup tree — including `telemetry_rams/DESIGN.md`
+prose — and the CLI auto-scan ingests the toolchain's own text (e.g. `scripts/audit.mjs:36`
+prose "shadow/backdrop-blur/bg-black"). Tailwind extracts class candidates from natural
+language. Result in `dist/rams.css` (all verified present, none worn by any mockup class
+attribute or implementation page):
+
+- `.shadow{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a)…box-shadow:…}` — bare `shadow` is worn by **no** mockup (mockups wear `shadow-md`/`shadow-sm`/`shadow-[…]` only)
+- `.blur{--tw-blur:blur(8px);…}` — bare `blur` likewise unworn anywhere
+- `.rounded` (see CR-01) entered the vocabulary the same way (e.g. `telemetry_rams/DESIGN.md:155` "devoid of rounded ornament")
+
+These carry off-token values (`#0000001a`, `8px`) into the artifact, and audit adjudication 1
+("utilities the mockups wear but the design doc rejects") does not cover them — they are not
+mockup-worn at all. The audit skips them only because no page harvests those classes, so the
+"zero off-token values" evidence holds by accident of non-usage.
+**Fix:** Scope `@source` to the four mockup markup files and exclude tooling prose from the
+auto-scan:
+```css
+@source "../../stitch_minimalist_route_navigator/active_route_guidance/code.html";
+@source "../../stitch_minimalist_route_navigator/navigate_map_overview/code.html";
+@source "../../stitch_minimalist_route_navigator/community_incident_reporter/code.html";
+@source "../../stitch_minimalist_route_navigator/telegram_live_traffic_feed/code.html";
+@source not "../scripts";
+@source not "../basemap";
+```
+After the fix, `.rounded`/`.shadow`/`.blur` should disappear from `dist/rams.css` (verify by
+grep); only mockup-worn classes remain in the compiled vocabulary.
+
+### WR-02: `familiesFromShorthand` logic bug — off-token families in `font:` shorthands pass the audit unflagged
+
+**File:** `theme/scripts/audit.mjs:449-455` (helper), consumed at `audit.mjs:439-441`
+**Issue:** The strip regex removes only the **first** shorthand token:
+`'700 14px/18px Arial'` → strips `'700 '` → leaves `'14px/18px Arial'`, which the
+`/^[0-9.]/` filter then drops as "numeric" — so the family list comes out empty and **no
+`font:` shorthand can ever be flagged**, regardless of family. Verified end-to-end:
+`.a-font-shorthand { font: 700 14px/18px Arial; }` produces zero violations (Arial is
+off-token and should be flagged per the T2 contract). The test suite only covers the
+`font-family:` longhand, so the hole is untested. The package's own convention is
+`font: var(--type-*)` everywhere, so nothing currently trips it — but the gate's contract
+(T2: "off-token font → flagged") is silently incomplete.
+**Fix:** Simplest contract-true fix — flag any literal (non-var) `font:` shorthand outright,
+matching the package convention:
+```js
+if (prop === 'font' && !/var\(/i.test(value)) {
+ violations.push('literal font: shorthand — use font: var(--type-*) (Rams typography tokens)');
+} else if (prop === 'font-family' && !/var\(/i.test(value)) {
+ violations.push(...fontViolations(value, tokens));
+}
+```
+(Alternatively repair `familiesFromShorthand` to iteratively strip weight and size/line-height
+components.) Add a T2-style test case for the shorthand form.
+
+### WR-03: Color-evasion gaps — named keywords, `oklch()`/`lab()`/`lch()`/`hwb()`/`color()`, and `text-shadow` all pass the audit
+
+**File:** `theme/scripts/audit.mjs:361-394` (`literalHexViolations` — hex/`rgb()`/`hsl()` only), `audit.mjs:419-429` (`shadowViolations` — `box-shadow`/`backdrop-filter`/`filter` only)
+**Issue:** Verified evasions (fixture run through the real audit — none flagged):
+`.b { color: red; }`, `.c { background: oklch(0.6 0.2 30); }`, `.e { fill: color(display-p3 1 0 0); }`,
+and `.h2 { text-shadow: 0 1px 0 red; }` (any non-hex `text-shadow`). Modern CSS Color 4
+functions and named keywords are fully unaudited escape hatches, and `text-shadow` (a shadow
+Rams forbids) is not in `shadowViolations`. For a tool whose purpose is "zero off-token
+values is evidence, not an eyeball claim", these are known-open doors that later phases can
+walk through accidentally (e.g. `color: white`, `oklch()` from a design-tool export).
+**Fix:** Extend the literal checks:
+```js
+const NAMED = new Set(['red','green','blue','white','black','gray','grey','yellow','orange',
+ 'purple','pink','brown','silver','gold','cyan','magenta','lime','navy','teal','olive',
+ 'maroon','aqua','fuchsia']);
+// in literalHexViolations: flag NAMED keywords on color-ish props
+// (keep currentcolor/inherit/transparent sanctioned);
+// flag /\b(?:oklch|oklab|lab|lch|hwb|color|color-mix)\(/ unless the value contains var(.
+// in shadowViolations: add text-shadow (any non-none value).
+```
+
+### WR-04: D-13 contract shortfall — spacing and typography numerics are not audited at all
+
+**File:** `theme/scripts/audit.mjs:433-446` (`checkDeclaration` — no spacing/type-size/weight/line-height properties), claims at `audit.mjs:8-9`, `theme/gallery.html:155`, `.planning/phases/01-design-tokens-rams-foundation/01-CONTEXT.md` (D-13)
+**Issue:** D-13 states the audit "flags any color/**spacing**/**typography**/radius value not
+in the token set", the audit header says "any color / typography / radius / shadow / blur
+value", and the gallery tells the approver "colors, typography, radius, shadows, blur **all**
+derive from theme/src/tokens.css". Verified: `padding: 13px; gap: 7px;`, `font-size: 17px;
+font-weight: 650; line-height: 1.7;` all pass unflagged. The typography check is font-family
+only; spacing is entirely absent. The evidence claim is broader than the implementation.
+**Fix:** Either implement the missing checks (spacing literals validated against the 8px-grid
+scale; `font-size`/`font-weight`/`line-height` validated against the `--type-*` scale) or
+amend the claim wording in `audit.mjs`'s header and `gallery.html` to "color families, font
+families, radius, shadows, blur" so the recorded DSGN-01 evidence matches what actually runs.
+
+### WR-05: Static server path guard uses prefix match without separator — sibling `theme*` paths escape the docroot
+
+**File:** `theme/scripts/screenshot.mjs:68-71`
+**Issue:** `join(THEME, decodeURIComponent(url.pathname))` then `target.startsWith(THEME)`.
+A URL-encoded `..%2FthemeX` decodes to `../themeX`, joins to `/…/araharah/themeX`, which
+**passes** the `startsWith('/…/araharah/theme')` check while being outside the docroot — the
+classic incomplete-prefix-traversal idiom. Exploitability is low (bound to `127.0.0.1`,
+ephemeral port, alive only during capture, no CORS headers), but the guard exists precisely
+to prevent this and fails at its boundary condition.
+**Fix:**
+```js
+import { sep } from 'node:path';
+// …
+const target = join(THEME, decodeURIComponent(url.pathname));
+if (target !== THEME && !target.startsWith(THEME + sep)) {
+ res.writeHead(403); res.end('forbidden'); return;
+}
+```
+
+## Info
+
+### IN-01: Dead `send` script
+
+**File:** `theme/package.json:13`
+**Issue:** `"send": "mkdir -p dist/fonts && cp -r fonts/geist fonts/space-grotesk dist/fonts/"`
+is referenced by nothing (not delegated from the root, not a dependency of any other script),
+and its output is unused — `dist/rams.css` references `url(../fonts/…)`, which resolves from
+`dist/` to the tracked `theme/fonts/` directly; nothing serves `dist/fonts/`.
+**Fix:** Remove the script (or document its purpose if one is intended).
+
+### IN-02: `playwright` is a runtime `dependency` of a CSS package
+
+**File:** `theme/package.json:8-10`
+**Issue:** `@araharah/theme` ships no runtime JS, yet `playwright` sits under
+`dependencies` — any consumer installing the theme package for its CSS pulls the browser
+toolchain as a prod dependency.
+**Fix:** Move `playwright` to `devDependencies` (it is only used by `scripts/screenshot.mjs`,
+a dev-time capture tool).
+
+### IN-03: `python3` dependency in an otherwise Node-only toolchain
+
+**File:** `theme/package.json:17`
+**Issue:** `basemap:preview` shells out to `python3 -m http.server 8123`, introducing a
+Python runtime requirement into the Node-first toolchain (AGENTS.md free-first/Node-first
+stack). A tiny Node static server already exists in `screenshot.mjs` and could be extracted
+and shared.
+**Fix:** Extract the `serveTheme()` server (with a port argument) into a small shared script,
+or accept and document the Python requirement.
+
+### IN-04: Root scripts miss the workspace's `test` and `basemap` entries
+
+**File:** `package.json:8-14`
+**Issue:** The root delegates `build`/`sheet`/`gallery`/`audit`/`capture` but not `test` or
+`basemap` — `npm test` at the repo root fails ("Missing script") even though the theme
+workspace defines a passing 16/16 test suite, and the basemap rebuild documented in
+`theme/basemap/build.mjs:31` must be invoked with the longer `--workspace` form.
+**Fix:** Add `"test": "npm run test --workspace theme"` and
+`"basemap": "npm run basemap --workspace theme"` to the root scripts.
+
+### IN-05: Font probing — no fetch timeout; partial substitutions unrecorded
+
+**File:** `theme/basemap/build.mjs:305-315` (`probeGlyph`), `build.mjs:321-345` (`resolveFontStacks`)
+**Issue:** (a) `fetch` has no `AbortSignal.timeout`, so a stalled glyphs endpoint hangs the
+basemap build indefinitely. (b) If *some* of a role's intended stacks are hosted (e.g.
+`'Geist Regular'` 200 but `'Space Grotesk Regular'` 404), `served.minor` silently becomes the
+partial list with no `rams-font-substitutions` record — undermining the "honest fallback
+provenance" the feature exists to provide. (Current committed output hit the clean
+all-404 → full-fallback path, so both are latent.)
+**Fix:** Add `{ signal: AbortSignal.timeout(10_000) }` to the probes; record a substitution
+entry whenever `served[kind]` differs from `FONT_INTENT[kind]`, not only on full fallback.
+
+### IN-06: Sheet inputs have no accessible name
+
+**File:** `theme/src/sheet.html:167-168`
+**Issue:** Both `input-field` text inputs are labeled by placeholder only — no ``/`aria-label` — leaving them nameless to assistive tech.
+**Fix:** Add `aria-label="Destination search"` / `aria-label="Bot command input"` (or wrap with visually-hidden label text), consistent with the Rams register.
+
+### IN-07: Mockup capture fallback is invisible in the gallery artifact
+
+**File:** `theme/scripts/screenshot.mjs:130-138`
+**Issue:** When the network render of a mockup fails, the committed `screen.png` is copied in
+as the gallery's "mockup reference render" — indistinguishable from a true render in the
+final artifact. Only the capture log records the substitution, so a DSGN-02 approver can
+unwittingly compare the implementation against a fallback capture made under different
+conditions (offline, no CDN fonts).
+**Fix:** Emit a machine-readable marker alongside the capture (e.g. a `screenshots/manifest.json`
+with `{ file, source: "network" | "fallback" }`) and/or note fallback occurrences in the
+phase summary where DSGN-02 is recorded.
+
+---
+
+**Verified-clean aspects (for the record):** `npm run audit` exits 0 on the real build (354
+declarations, 97 rules, 298 compiled-vocabulary rules out of scope, 49 classes harvested);
+`node scripts/audit.test.mjs` passes 16/16; `liberty-rams.json` machine-verified: 110 layers,
+no accent hex/rgb forms, no `line-gradient`, no `icon-color` paints, no non-zero
+`text-halo-blur`, only the 3 documented untouched layers (`road_area_pattern`,
+`road_one_way_arrow`, `road_one_way_arrow_opposite`), `building-3d` removed with reason
+recorded, Noto Sans font substitution honestly recorded in metadata; all 7 self-hosted woff2
+present and matched 1:1 by the `@font-face` URLs; `--shell-*` slots + dark-scheme media query
+present in the compiled CSS; the basemap proof page reads the route color from the token
+package at runtime; the audit's used-class harvesting and utilities-layer scoping behave as
+documented (verified with fixture pages); the audit does flag `.rounded` the moment any page
+wears it.
+
+_Reviewed: 2026-10-22T01:08:55Z_
+_Reviewer: OpenCode (gsd-code-reviewer)_
+_Depth: standard_
diff --git a/.planning/phases/01-design-tokens-rams-foundation/01-VERIFICATION.md b/.planning/phases/01-design-tokens-rams-foundation/01-VERIFICATION.md
new file mode 100644
index 0000000..341e9c1
--- /dev/null
+++ b/.planning/phases/01-design-tokens-rams-foundation/01-VERIFICATION.md
@@ -0,0 +1,162 @@
+---
+phase: 01-design-tokens-rams-foundation
+verified: 2026-10-22T02:55:00Z
+status: passed
+score: 13/14 plan must-haves verified literally (14/14 at goal level); 4/4 roadmap success criteria verified
+overrides_applied: 0
+re_verification: # initial — no previous VERIFICATION.md existed
+ previous_status: null
+ previous_score: null
+ gaps_closed: []
+ gaps_remaining: []
+ regressions: []
+gaps: []
+deferred:
+ - truth: "Basemap POI icons come from the hosted colored OFM sprite (not token-derived)"
+ addressed_in: "Phase 3 / Phase 5 (first product map phase)"
+ evidence: "Logged in phase deferred-items.md #1; Phase 3 SC1 renders the live position on the Rams basemap, Phase 5 renders route display — treatment candidates (text-only POIs, custom grayscale sprite) attached to those map phases"
+ - truth: "Sprite-pattern layers (landcover_wetland, road_area_pattern) left un-derived"
+ addressed_in: "Phase 3 / Phase 5"
+ evidence: "Logged in phase deferred-items.md #3; visible only at zooms above the proof view"
+ - truth: "Self-hosted pbf glyph route for Rams map-label fonts (Space Grotesk/Geist as map glyphs)"
+ addressed_in: "Candidate for Phase 2+ (Worker infra) / product map phases"
+ evidence: "Recorded in liberty-rams.json metadata.rams-font-substitutions reason: 'revisit with a Worker-served glyphs route'; no Phase 1 SC requires self-hosted glyphs, and no later phase SC claims it — tracked here for visibility"
+---
+
+# Phase 1: Design Tokens + Rams Foundation — Verification Report
+
+**Phase Goal:** The Telemetry Rams design system exists as one centralized, importable token set — including map basemap cartography — with a user-approved mockup-vs-implementation gallery proving the fidelity bar before any product screen is built.
+**Verified:** 2026-10-22T02:55:00Z
+**Status:** passed
+**Re-verification:** No — initial verification
+
+## Goal Achievement
+
+### Observable Truths (roadmap success criteria = the contract)
+
+| # | Truth (Roadmap SC) | Status | Evidence |
+|---|--------------------|--------|----------|
+| 1 | Every rendered Rams component derives exclusively from the centralized token set — audit sweep finds zero off-token values | ✓ VERIFIED | `npm run audit` re-run on freshly rebuilt dist: **AUDIT CLEAN — 354 declarations, 0 off-token values** (55 colors / 9 radius tokens all zero / 7 families / 6 spacing / 10 type sizes / 9 trackings). `border-radius` forms in dist: `0`, `var(--radius)`, `3.40282e38px` (sanctioned rounded-full clamp) — no `.25rem`. tokens.css is the audit allowlist source (`DEFAULT_TOKENS = src/tokens.css`). |
+| 2 | Side-by-side gallery exists and is user-approved (DSGN-02 mechanism) | ✓ VERIFIED | `theme/gallery.html` (290 lines, CDN-free, token-driven): 4 mockup-vs-sheet rows + basemap row + token ledger (names only) + approval strip; ` ` links to all 5 committed captures (visually inspected — real renders). `01-04-DSGN-02-APPROVAL.md` records verbatim user response **"approved"** (2026-10-22T00:51:39Z), scope = fidelity bar + basemap direction. |
+| 3 | Rams-styled MapLibre basemap renders in a browser | ✓ VERIFIED | `theme/basemap/liberty-rams.json` (110 layers, `metadata.rams-cartography.derived-from = theme/src/tokens.css` with per-role derivation formulas) + `index.html` MapLibre proof page (loads `./liberty-rams.json`, route color read at runtime via `getComputedStyle('--color-signal-orange')`). Committed real-browser capture `screenshots/basemap-kl.png` inspected: grayscale/muted KL metro, muted labels, zero orange except the reserved route vector. |
+| 4 | Design authority is singular (conflict resolved) | ✓ VERIFIED | Root `DESIGN.md` carries the SUPERSEDED-FOR-SPECIFICS banner (conflicting Helvetica/Inter typography + palette explicitly marked superseded, values delegated to the package). `README.md` contains zero hex/px. Mockup tree untouched (git porcelain clean — read-only per D-06, `@source` scoped to its markup only). One token package (`theme/`), all scripts delegate to it. |
+
+**Score (goal level):** 4/4 success criteria verified
+
+### Plan-Level Must-Haves (merged from PLAN frontmatters)
+
+| Plan | Truth | Status | Evidence |
+|------|-------|--------|----------|
+| 01 | Tokens importable as plain CSS (`@theme static` + `:root`) | ✓ VERIFIED | tokens.css 176 lines; dist carries token vars |
+| 01 | Build produces compiled dist with Tailwind v4 utilities | ✓ VERIFIED | `npm run build` → dist/rams.css (90ms, v4.3.3); audit harvests 49 classes from 3 pages |
+| 01 | Zero Google Fonts CDN refs; self-hosted fonts | ✓ VERIFIED | grep for `fonts.googleapis|fonts.gstatic|cdn.tailwindcss` in theme/ → 0 hits; 7 woff2 files present + `@font-face` blocks + `dist/fonts` |
+| 01 | DESIGN.md superseded banner | ✓ VERIFIED | lines 1–3 of DESIGN.md |
+| 02 | Sheet displays every mockup component family | ✓ VERIFIED | Visual inspection of `sheet-full.png`: typography specimen (D-11), buttons, inputs, chips/badges, telemetry card, maneuver banner, checkboxes/toggle |
+| 02 | Legibility specimen included (D-11) | ✓ VERIFIED | SEC-00 // LEGIBILITY section, 13 `--type-*` specimens |
+| 02 | Sheet dist CSS contains zero off-token values | ✓ VERIFIED | audit exit 0 post-rebuild; components.css = 104 `var(--` refs, no literal hex (only documented 18×18 checkbox geometry, adjudication 6) |
+| 03 | Basemap renders: grayscale landing, muted water/roads/labels, zero signal-orange in basemap | ✓ VERIFIED | Capture inspection + metadata; audit-style accent guard in build.mjs (`assertNoAccent`) |
+| 03 | Values derived from tokens, not hand-tuned | ✓ VERIFIED | build.mjs parses tokens.css; metadata records `desaturate(tokenHex, …)` per role |
+| 03 | Map labels use self-hosted Space Grotesk/Geist assets (D-10) | ⚠ VERIFIED-WITH-RECORDED-DEVIATION | See "Recorded deviation" below — hosted OpenFreeMap glyphs endpoint only serves Noto Sans (Space Grotesk/Geist 404 at build time, probed); closest hosted stack substituted, recorded in `metadata.rams-font-substitutions` with reason + revisit path. Pre-planned in 01-03 plan body, REVIEW-adjudicated clean, and inside the basemap render the user explicitly approved. |
+| 04 | Audit is scripted evidence, zero off-token values (D-13) | ✓ VERIFIED | 38/38 regression tests pass (incl. T9–T12 evasion tests); exit 2 radius-drift guard |
+| 04 | Static side-by-side gallery vs all 4 mockups (D-12) | ✓ VERIFIED | gallery.html, 4 rows + basemap row, all ` `s resolve |
+| 04 | Playwright real-browser capture script committed (D-14) | ✓ VERIFIED | scripts/screenshot.mjs committed; 6/6 captures present and visually verified non-blank |
+| 04 | User viewed gallery + basemap and approved | ✓ VERIFIED | Approval record, verbatim "approved" |
+
+**Score (plan level):** 13 verified literally / 14 (1 verified-with-recorded-deviation, no goal-level gap)
+
+### Recorded Deviation (D-10 map-label glyph stacks) — suggested formal override
+
+Map label glyph stacks are `Noto Sans Bold / Noto Sans Regular`, not self-hosted Space Grotesk/Geist. MapLibre label glyphs must be served as pbf glyph ranges; the OpenFreeMap glyphs endpoint hosts only Noto Sans and self-hosted woff2 cannot serve glyph ranges without a glyphs route (Worker arrives in Phase 2). The substitution was pre-planned in the 01-03 plan body, is machine-recorded in `metadata.rams-font-substitutions` (intended/served/reason), was adjudicated clean by 01-REVIEW, and the user approved the rendered basemap that displays it. Rams-type *roles* are honored (bold-major / regular-minor hierarchy, muted matte labels). To formalize in the record, the developer may add to this file's frontmatter:
+
+```yaml
+overrides:
+ - must_have: "Map labels use the self-hosted Space Grotesk / Geist assets (D-10)"
+ reason: "OpenFreeMap glyphs endpoint hosts only Noto Sans (woff2 cannot serve pbf glyph ranges pre-Worker); closest hosted stack substituted, recorded in style metadata, and covered by the user's DSGN-02 basemap approval"
+ accepted_by: "(developer)"
+ accepted_at: "(timestamp)"
+```
+
+### Required Artifacts
+
+| Artifact | Expected | Status | Details |
+| -------- | -------- | ------ | ------- |
+| `theme/src/tokens.css` | Token package (55 colors, 10 spacing, 9 radius all zero, 13 type vars) | ✓ VERIFIED | 176 lines; `--radius: 0px` bare key + family zeroed (CR-01 fix commit 61d15c2 present) |
+| `theme/src/theme.css` | @import tokens + @font-face + scoped @source | ✓ VERIFIED | imports tokens.css + components.css; `@source not ../scripts` / `../basemap` (WR-01 fix present) |
+| `theme/src/components.css` | var()-only component classes | ✓ VERIFIED | 104 var() refs, no literal hex |
+| `theme/src/sheet.html` | Component sheet consuming dist/rams.css | ✓ VERIFIED | link at line 15; renders all 7 component families (screenshot inspected) |
+| `theme/dist/rams.css` | Built stylesheet | ✓ VERIFIED | rebuilt this pass; radius forms token-only; no `.shadow{`/`.blur{` (WR-01) |
+| `theme/basemap/build.mjs` + `liberty-rams.json` + `index.html` | Token-derived basemap + render proof | ✓ VERIFIED | derivation provenance in metadata; style JSON valid; proof page wired |
+| `theme/scripts/audit.mjs` (+ test harness) | Automated D-13 audit | ✓ VERIFIED | 814 lines; scans dist, parses tokens.css allowlist, 38/38 tests |
+| `theme/scripts/screenshot.mjs` | Playwright capture toolchain (D-14) | ✓ VERIFIED | WR-05 docroot guard fix present (`startsWith(THEME + sep)`) |
+| `theme/gallery.html` | DSGN-02 gallery (min 80 lines) | ✓ VERIFIED | 290 lines, CDN-free |
+| `theme/fonts/{geist,space-grotesk}/*.woff2` | Self-hosted typefaces | ✓ VERIFIED | 7 files |
+| `.gitignore` (+ Zone scrub) | Repo hygiene | ✓ VERIFIED | 0 `*:Zone.Identifier` files remain; secrets/node_modules/dist entries present |
+| `package.json` (root) | Workspace delegates | ✓ VERIFIED | build/sheet/gallery/audit/capture → theme workspace |
+
+### Key Link Verification
+
+| From | To | Via | Status | Details |
+| ---- | -- | --- | ------ | ------- |
+| theme/src/theme.css | theme/src/tokens.css | CSS @import | ✓ WIRED | line 14 |
+| theme build | theme/dist/rams.css | tailwindcss CLI | ✓ WIRED | ran this pass (90ms) |
+| theme/src/sheet.html | theme/dist/rams.css | ` ` | ✓ WIRED | line 15 |
+| theme/src/components.css | tokens.css | var(--color-*)/var(--spacing-*) | ✓ WIRED | 104 refs; literal scan clean |
+| theme/basemap/build.mjs | theme/src/tokens.css | token parse | ✓ WIRED | metadata `derived-from`; derivation report to stdout |
+| theme/basemap/index.html | liberty-rams.json | MapLibre style URL | ✓ WIRED | line 115 |
+| theme/basemap/index.html | token package | getComputedStyle at runtime | ✓ WIRED | reads `--color-signal-orange`/`--color-matte-black` |
+| theme/scripts/audit.mjs | tokens.css (allowlist) + dist/rams.css (scan) | default paths | ✓ WIRED | exit 0 run; exit-1/2 behavior covered by 38 tests |
+| theme/scripts/screenshot.mjs | theme/screenshots/* | Playwright captures | ✓ WIRED | 6/6 captures on disk |
+| theme/gallery.html | theme/screenshots/* | ` ` rows | ✓ WIRED | 9 img elements incl. basemap row |
+
+### Data-Flow Trace (Level 4)
+
+| Artifact | Data Variable | Source | Produces Real Data | Status |
+| -------- | ------------- | ------ | ------------------ | ------ |
+| basemap/index.html | route placeholder color | `getComputedStyle(--color-signal-orange)` from dist/rams.css | Yes (token value, not hand-tuned) | ✓ FLOWING |
+| liberty-rams.json | all cartography colors | tokens.css parse + desaturate() | Yes (provenance metadata per role) | ✓ FLOWING |
+| sheet.html / gallery.html | styles | dist/rams.css tokens | Yes (renders verified visually) | ✓ FLOWING (static design artifacts — no dynamic data expected) |
+
+### Behavioral Spot-Checks
+
+| Behavior | Command | Result | Status |
+| -------- | ------- | ------ | ------ |
+| Build produces dist | `npm run build --workspace theme` | Done in 90ms, v4.3.3 | ✓ PASS |
+| Audit sweep zero off-token | `npm run audit` | AUDIT CLEAN — 354 declarations, 0 off-token (exit 0), re-run post-rebuild | ✓ PASS |
+| Theme test suite | `node theme/scripts/audit.test.mjs` | 38 PASS / 0 FAIL (first invocation hit a stale `/tmp/opencode/audit-test` symlink from the fixer's sandbox — environment artifact, not a test failure; clean re-run passed) | ✓ PASS |
+| CR-01 fix: bare `rounded` var-driven | grep dist | `.rounded` → `border-radius:var(--radius)`, `--radius:0px` emitted; no `.25rem` | ✓ PASS |
+| Basemap renders as Rams cartography | inspect committed `screenshots/basemap-kl.png` (real-browser capture) | Grayscale/muted map, muted labels, orange only on reserved route vector | ✓ PASS |
+| Sheet renders all component families | inspect committed `screenshots/sheet-full.png` | Typography specimen + buttons + inputs + chips + card + banner + switches, zero radius | ✓ PASS |
+| Renders without committed captures | serve theme/ + open gallery.html live | Not re-run live — captures committed post-fix and no worn class changed rendering between capture and now (`.rounded`/`.shadow`/`.blur` worn by zero pages) | ? SKIP (evidence already real-browser + user-approved) |
+
+### Requirements Coverage
+
+| Requirement | Source Plan(s) | Description | Status | Evidence |
+| ----------- | -------------- | ----------- | ------ | -------- |
+| DSGN-01 | 01-01, 01-02, 01-03 | All screens render from a centralized token set — no off-token values; basemap cartography follows the same tokens | ✓ SATISFIED | Audit exit 0 (scripted evidence, D-13); tokens.css single source; basemap derived per-role from tokens (D-09); component/basemap renders verified |
+| DSGN-02 | 01-04 | Mockup-vs-implementation gallery reviewed for fidelity before proceeding | ✓ SATISFIED | gallery.html + Playwright captures + recorded verbatim approval ("approved", 00:51:39Z); mechanism documented for per-phase reuse |
+
+**Orphaned requirements:** none — REQUIREMENTS.md traceability maps exactly DSGN-01/DSGN-02 to Phase 1, and both are claimed by plans. (Info: the traceability table's "Pending" checkbox text was not flipped to Complete — doc-maintenance nit, not a gap.)
+
+### Anti-Patterns Found
+
+| File | Line | Pattern | Severity | Impact |
+| ---- | ---- | ------- | -------- | ------ |
+| theme/src/theme.css | 31–34 | `@source` scans mockup markup (Tailwind Play-CDN replacement pattern) | ℹ️ Info | Intentional and deliberately narrowed (WR-01) — prose leakage tests exist (T5) |
+| theme/basemap/index.html | 127 | "PLACEHOLDER ROUTE VECTOR" | ℹ️ Info | Documented scope staging — route rendering is Phase 5 per plan; reads color from tokens at runtime |
+| stitch_minimalist_route_navigator/*/code.html | — | legacy inline `onclick` demo handlers | ℹ️ Info | Read-only mockups (D-06), untouched by design |
+| DESIGN.md | 31–39 | hex/px restated in legacy philosophy prose | ℹ️ Info | Under explicit SUPERSEDED-FOR-SPECIFICS banner naming palette/typography as superseded; README carries zero numerics; D-05 pre-authorized keeping philosophy prose |
+
+No TODO/FIXME/stub markers; no blocker or warning anti-patterns.
+
+### Human Verification Required
+
+None outstanding. The phase's blocking human checkpoint (DSGN-02 gallery + basemap review) was completed on 2026-10-22 with a recorded verbatim approval — the one item a human must decide in this phase is already decided and archived (`01-04-DSGN-02-APPROVAL.md`). Visual fidelity, real-browser rendering, and basemap appearance were all covered by that approved review, and the committed captures inspected in this verification match what was approved (no worn class changed rendering between capture time and the post-fix state).
+
+### Gaps Summary
+
+No goal-level gaps. All four roadmap success criteria verified against the live codebase, with the code-review blocker fix (CR-01) and all five warnings (WR-01..05) confirmed present in the artifacts and the 38-test regression suite green. One documented, pre-adjudicated, user-approved deviation exists at plan-truth level (D-10 map-label glyph stacks → closest hosted stack, machine-recorded) — it does not block the phase goal, has a recorded revisit path, and a formal override entry is suggested above if the developer wants it in the durable record.
+
+---
+
+_Verified: 2026-10-22T02:55:00Z_
+_Verifier: OpenCode (gsd-verifier)_
diff --git a/.planning/phases/01-design-tokens-rams-foundation/deferred-items.md b/.planning/phases/01-design-tokens-rams-foundation/deferred-items.md
new file mode 100644
index 0000000..936f93c
--- /dev/null
+++ b/.planning/phases/01-design-tokens-rams-foundation/deferred-items.md
@@ -0,0 +1,11 @@
+# Phase 01 — Deferred Items (out-of-scope discoveries)
+
+Log of issues found during execution that belong to other plans/phases.
+Do NOT fix here. (Executor scope boundary: only fix issues directly
+caused by the current task's changes.)
+
+| # | Found during | Item | Disposition |
+|---|--------------|------|-------------|
+| 1 | plan 03 (basemap) | Liberty POI icons come from the hosted ofm sprite (`https://tiles.openfreemap.org/sprites/ofm_f384/ofm`) as colored raster icons — outside the plan's paint-property derivation list (`poi_r1/r7/r20` icon-image; not visible at the z13 proof view since POIs appear z15+) | Candidate treatment for the first product map phase (Phase 3 shell / Phase 5 guidance): text-only POIs, icon-opacity reduction, or a custom grayscale sprite served from the Worker |
+| 2 | plan 03 (basemap) | Upstream liberty warnings: `Expected value to be of type number, but found null instead.` ×3 at the KL view — verified present with the ORIGINAL untransformed liberty style too (scratch comparison page) | Upstream tile-data quirk; not caused by the Rams transform; no action in this phase |
+| 3 | plan 03 (basemap) | Sprite-pattern layers left untouched (no color paint props to derive): `landcover_wetland` (wetland texture pattern), `road_area_pattern` | Visible only at higher zooms; candidate for a custom Rams pattern sprite in a later map phase |
diff --git a/.planning/phases/02-bot-skeleton-initdata-security/02-01-PLAN.md b/.planning/phases/02-bot-skeleton-initdata-security/02-01-PLAN.md
new file mode 100644
index 0000000..a30d3c7
--- /dev/null
+++ b/.planning/phases/02-bot-skeleton-initdata-security/02-01-PLAN.md
@@ -0,0 +1,288 @@
+---
+phase: 02-bot-skeleton-initdata-security
+plan: 01
+type: execute
+wave: 1
+depends_on: []
+files_modified:
+ - package.json
+ - bot/package.json
+ - bot/tsconfig.json
+ - bot/wrangler.jsonc
+ - bot/.gitignore
+ - bot/.dev.vars.example
+ - bot/src/config.ts
+ - bot/test/harness.mjs
+ - bot/test/runner.mjs
+ - bot/test/initdata.test.mjs
+autonomous: true
+requirements: [ONBD-01, PRIV-01, PRIV-02]
+must_haves:
+ truths:
+ - "D-09/D-10/D-11: backend lives in a `bot/` TypeScript workspace sibling to `theme/`, with the explicit src/ tree contracted via bot/src/config.ts"
+ - "D-12: npm test -w bot runs a zero-dependency hand-rolled harness (audit.test.mjs shape) reporting PASS/FAIL/exit codes — substrate for all phase REQ verification"
+ - "D-15/D-17: the initdata vector suite RED-demonstrates the security-gate contract BEFORE implementation exists (TDD proof for RESEARCH Pitfalls 1–3), with no secret value ever in the repo"
+ - "D-11/D-15 contracts reach later plans: Env interface + wrangler entry points exist before implementation"
+ artifacts:
+ - path: "bot/package.json"
+ provides: "Workspace scripts/deps (grammy ^1.46.0, wrangler ^4.151.3, TS ^5.9.3)"
+ contains: "@araharah/bot"
+ - path: "bot/src/config.ts"
+ provides: "Env contract consumed by plans 02/03"
+ contains: "export interface Env"
+ - path: "bot/test/runner.mjs"
+ provides: "D-12 hand-rolled runner, auto-discovers *.test.mjs, suite-load failures become FAILs"
+ - path: "bot/test/initdata.test.mjs"
+ provides: "Vector suite T1–T7 (known-good/tamper/stale/boundary/order/malformed/RFC-4231 pin)"
+ - path: "bot/wrangler.jsonc"
+ provides: "Worker config: main src/webhook.ts + chat_sessions KV binding"
+ contains: "chat_sessions"
+ key_links:
+ - from: "bot/test/initdata.test.mjs"
+ to: "bot/src/security/initdata.ts"
+ via: "import "../src/security/initdata.ts" (RED until plan 02)"
+ pattern: "initdata\\.ts"
+ - from: "bot/wrangler.jsonc"
+ to: "src/webhook.ts"
+ via: "main entry binding"
+ pattern: "\"main\": \"src/webhook.ts\""
+---
+
+
+Stand up the `bot/` npm workspace (D-09/D-10/D-11) with the hand-rolled test harness (D-12) and a RED initdata vector suite — the Wave-0 substrate every later verification run (`npm test -w bot`) executes on.
+
+Purpose: Plans 02 and 03 fill in the security core and command surface against this scaffold; the initdata suite written here goes RED now and goes GREEN in plan 02 (TDD proof of the phase's security exit criterion).
+Output: Installable workspace, type-checkable `src/config.ts` contract, runner + RED vector suite.
+
+
+
+@$HOME/.config/opencode/get-shit-done/workflows/execute-plan.md
+@$HOME/.config/opencode/get-shit-done/templates/summary.md
+
+
+
+@.planning/PROJECT.md
+@.planning/ROADMAP.md
+@.planning/STATE.md
+@.planning/phases/02-bot-skeleton-initdata-security/02-CONTEXT.md
+@.planning/phases/02-bot-skeleton-initdata-security/02-RESEARCH.md
+@.planning/phases/02-bot-skeleton-initdata-security/02-VALIDATION.md
+@theme/package.json
+@theme/scripts/audit.test.mjs
+
+
+
+
+From bot/src/config.ts (created here, consumed by plans 02/03):
+```typescript
+export interface Env {
+ BOT_TOKEN: string; // wrangler secret (D-15) — Telegram Bot API token from @BotFather
+ WEBHOOK_SECRET: string; // wrangler secret (D-15) — value Telegram echoes in X-Telegram-Bot-Api-Secret-Token
+ chat_sessions: KVNamespace; // TTL-only namespace — first durable KV namespace (PRIV-02)
+}
+```
+
+Test harness API this plan creates (from `bot/test/harness.mjs`, consumed by every later `*.test.mjs`):
+```javascript
+export function test(name, fn) // register one async case; fn() throws → FAIL
+```
+
+Runner contract (from `bot/test/runner.mjs`):
+- Scans `bot/test/*.test.mjs`, dynamic-imports each; a file that fails to import is recorded as one failing case (`suite load failed: `), not a crash
+- Prints `PASS`/`FAIL ` per case + summary `N passed, M failed`; exit 0 only when M = 0
+
+
+
+
+
+
+ task 1: bot workspace scaffold with Env contract (D-09, D-10, D-11, D-15)
+ package.json, bot/package.json, bot/tsconfig.json, bot/wrangler.jsonc, bot/.gitignore, bot/.dev.vars.example, bot/src/config.ts
+
+ - ./package.json (current root — has workspaces ["theme"], delegate scripts pattern to extend)
+ - ./theme/package.json (Phase 1 workspace precedent: scripts/deps shape)
+ - ./theme/scripts/audit.test.mjs (runner shape D-12 builds on — read the header + structure)
+ - .planning/phases/02-bot-skeleton-initdata-security/02-CONTEXT.md (D-09/D-10/D-11/D-15 exact wording)
+ - .planning/phases/02-bot-skeleton-initdata-security/02-RESEARCH.md (Standard Stack versions + Recommended Project Structure)
+
+
+1. **Root package.json (edit, don't replace):** workspaces becomes `["theme", "bot"]` (D-09). Add delegate scripts mirroring the Phase 1 pattern:
+ ```json
+ "bot:test": "npm test -w bot",
+ "bot:typecheck": "npm run typecheck -w bot",
+ "bot:dev": "npm run dev -w bot",
+ "bot:deploy": "npm run deploy -w bot",
+ "bot:commands": "npm run commands -w bot"
+ ```
+ Keep existing theme scripts untouched.
+
+2. **bot/package.json (new):**
+ ```json
+ {
+ "name": "@araharah/bot",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "test": "node test/runner.mjs",
+ "typecheck": "tsc --noEmit",
+ "dev": "wrangler dev",
+ "deploy": "wrangler deploy",
+ "commands": "node scripts/register-commands.mjs"
+ },
+ "dependencies": { "grammy": "^1.46.0" },
+ "devDependencies": {
+ "wrangler": "^4.151.3",
+ "typescript": "^5.9.3",
+ "@cloudflare/workers-types": "^5.20261022.1",
+ "@types/node": "^26.6.5"
+ }
+ }
+ ```
+ Versions are registry-verified in RESEARCH.md — use exactly these ranges.
+
+3. **bot/tsconfig.json:** strict, Workers-native, noEmit (wrangler/esbuild does the real build; `allowImportingTsExtensions` pairs with noEmit and matches Node 24 type-stripping for the scripts/ D-12 runner):
+ ```json
+ {
+ "compilerOptions": {
+ "target": "ES2023",
+ "lib": ["ES2023"],
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "types": ["@cloudflare/workers-types"],
+ "strict": true,
+ "noUncheckedIndexedAccess": true,
+ "verbatimModuleSyntax": true,
+ "allowImportingTsExtensions": true,
+ "noEmit": true,
+ "skipLibCheck": true,
+ "forceConsistentCasingInFileNames": true
+ },
+ "include": ["src"]
+ }
+ ```
+ Constraint for all bot/ TS written in this phase: **erasable-only TypeScript** (no enums, no parameter properties, no namespaces — only type stripping survives). Node 24 runs `.ts` natively under this constraint [VERIFIED in RESEARCH.md].
+
+4. **bot/wrangler.jsonc (config, not secrets — D-17):**
+ ```jsonc
+ {
+ // KV namespace id is filled in plan 03 task 1 after `npx wrangler kv namespace create chat_sessions`
+ "name": "araharah-bot",
+ "main": "src/webhook.ts",
+ "compatibility_date": "2026-10-22",
+ "kv_namespaces": [{ "binding": "chat_sessions", "id": "KV_ID_PLACEHOLDER" }]
+ }
+ ```
+
+5. **bot/.gitignore:** exactly `.dev.vars` and `.wrangler/` (D-15/D-17 — secret hygiene from the first commit; Phase 1 precedent for self-hosted assets mirrors backend secrets).
+
+6. **bot/.dev.vars.example (shape only, no real values):**
+ ```
+ # Copy to .dev.vars and fill for local wrangler dev (D-15). NEVER commit .dev.vars.
+ BOT_TOKEN=
+ WEBHOOK_SECRET=
+ WEBHOOK_URL=
+ ```
+ (WEBHOOK_URL is set once after the first deploy; `scripts/set-webhook.mjs` in plan 03 reads it.)
+
+7. **bot/src/config.ts (interface contract only — no implementation beyond types):** the `Env` interface exactly as in `` above (D-10: grammY + workers-types first-class).
+
+8. Run `npm i -w bot` (creates/updates root package-lock.json; verify grammy@1.46.x and wrangler@4.15x land under node_modules/bot's tree).
+
+
+ npm i -w bot && npm run bot:typecheck; test -f bot/src/config.ts && test -f bot/.dev.vars.example && test -f bot/.gitignore && git check-ignore bot/.dev.vars && echo SCAFFOLD_OK
+
+
+ - `npm run bot:typecheck` exits 0 (src/config.ts type-checks against @cloudflare/workers-types)
+ - `bot/src/config.ts` contains `export interface Env` with members `BOT_TOKEN`, `WEBHOOK_SECRET`, `chat_sessions: KVNamespace`
+ - root package.json contains `"workspaces": ["theme", "bot"]` (order irrelevant) and script keys `bot:test`, `bot:deploy`
+ - `bot/package.json` contains `"name": "@araharah/bot"` and dependency `grammy` in the ^1.46.0 range
+ - `bot/wrangler.jsonc` contains `"main": "src/webhook.ts"` and binding `chat_sessions`
+ - `bot/.gitignore` contains both `.dev.vars` and `.wrangler/`
+ - `bot/.dev.vars.example` contains `BOT_TOKEN=` and `WEBHOOK_SECRET=` and no actual token value
+ - `git check-ignore bot/.dev.vars` exits 0 (secret mirror is actually ignored)
+
+ bot workspace installs, typechecks clean, wrangler entry points and secret-mirror files exist; nothing secret-value is committed (D-15/D-17).
+
+
+
+ task 2: Wave 0 harness + RED initdata vector suite (D-12, TDD RED phase)
+ bot/test/harness.mjs, bot/test/runner.mjs, bot/test/initdata.test.mjs
+
+ - ./theme/scripts/audit.test.mjs (the D-12 "same shape" precedent — node:assert, exit codes, /tmp/opencode-style out-of-repo fixtures)
+ - {bot/.dev.vars.example path too} not needed here; read .planning/phases/02-bot-skeleton-initdata-security/02-RESEARCH.md § "Validation Architecture" and § Code Examples (vector contract)
+
+
+ All cases begin failing (RED) because `../src/security/initdata.ts` does not exist yet — the runner must report the failed suite load as FAIL cases, not a crash. Plan 02 task 1 makes them GREEN. Vector contract (RFC-4231 cross-pinned in-suite):
+ - T1 known-good: initData built with test token `12345:TESTTOKEN`, fields `user={"id":42,"first_name":"Zul","language_code":"ms"}`, `auth_date=`, `hash=` → validateInitData resolves `{ ok: true }` and exposes `user.id === 42`, `languageCode === "ms"`
+ - T2 tampered: any char of `hash` flipped → `{ ok:false, reason:"tampered" }`
+ - T3 stale: `auth_date` = now − 3600 with default 900s window → `reason:"stale"` (D-13)
+ - T4 freshness boundary: age 899s → ok; age 901s → stale
+ - T5 order-independence: fields inserted out of alphabetical order (`user` before `auth_date`) still validate (RESEARCH Pitfall 1)
+ - T6 malformed: missing `hash` field → `reason:"malformed"`; `auth_date` non-numeric → `reason:"malformed"`; `user` JSON missing `id` → `reason:"malformed"`
+ - T7 suite self-check: `node:crypto` HMAC(key="key", data="data") hex equals `5031fe3d989c6d1537a013fa6e739da23463fdaec3b70137d828e36ace221bd0` (independence pin — src under test uses SubtleCrypto, so primitive cross-check is real)
+
+
+1. **bot/test/harness.mjs** (the registry):
+ ```javascript
+ const tests = [];
+ export function test(name, fn) { tests.push({ name, fn }); }
+ export const registry = tests;
+ ```
+ Zero dependencies (D-12).
+
+2. **bot/test/runner.mjs** (entry; D-12 shape from audit.test.mjs):
+ - `import { readdirSync } from "node:fs"` + `path` from `node:path`; resolve suites as `readdirSync(import.meta.dirname).filter(f => f.endsWith(".test.mjs")).sort()`
+ - For each suite: `await import(`./${file}`)` inside try/catch. On load error, push `{ name: "suite load failed: " + file, fn: () => { throw loadErr; } }` into the registry (RED survives, never crashes the run)
+ - Run all registered cases in order: pass → print `PASS `; throw → print `FAIL ` + `error.message`/stack, keep going
+ - End with /\n summary `\n[runner] N passed, M failed\n` and `process.exitCode = M > 0 ? 1 : 0`
+3. **bot/test/initdata.test.mjs:** implement T1–T7 above as async cases via `import { test } from "./harness.mjs"` and `import { validateInitData } from "../src/security/initdata.ts"` (missing import = the RED state). The helper that builds a signed initData string must live IN the test file using `node:crypto` (createHmac twice: secret = HMAC(key="WebAppData", data=botToken); hash = HMAC(key=secretHexBytes, data=dataCheckString)) — independent of the SubtleCrypto path under test (T7 pins both primitives). Fixtures stay in-process; nothing written into the repo.
+
+
+ npm run bot:typecheck && npm test -w bot; echo "exit=$?"
+
+
+ - `npm test -w bot` prints at least 7 FAIL lines matching the T1–T7 contract and exits 1 (documented RED)
+ - The runner summary line matches `N passed, M failed` with M > 0
+ - A suite-load failure prints as `FAIL suite load failed: initdata.test.mjs` (or per-case FAILs) — the runner exits with code 1 after printing all summaries, never crashes mid-run
+ - `node test/runner.mjs` with zero suite failures (temporarily verify by `cd bot && node test/runner.mjs` after hiding suites) prints `[runner] 0 passed, 0 failed` and exits 0
+ - `npm run bot:typecheck` remains exit 0 (tests are .mjs, outside tsc include — intentional; they run via Node type-stripping)
+
+ Runner + harness exist with zero dependencies; the initdata vector suite is written to its final contract and demonstrably RED (plan 02 goes GREEN against it); D-12 replicated in a second workspace.
+
+
+
+
+
+## Trust Boundaries
+
+| Boundary | Description |
+|----------|-------------|
+| repo → git history | Secrets and local-env values cross into permanent history on any accidental `git add` |
+| .dev.vars → local dev runtime | Local secrets enter the dev process |
+| npm registry → workspace tree | Supply-chain surface when installing grammy/wrangler/typescript |
+
+## STRIDE Threat Register
+
+| Threat ID | Category | Component | Disposition | Mitigation Plan |
+|-----------|----------|-----------|-------------|-----------------|
+| T-02-04 | Information Disclosure | bot/.gitignore, bot/.dev.vars.example | mitigate | `.dev.vars` ignored in the SAME commit that introduces the secret mirror (D-15/D-17); verify with `git check-ignore` in acceptance criteria |
+| T-02-07 | Tampering | bot/wrangler.jsonc, bot/tsconfig.json | mitigate | Exact registry-verified versions (^1.46.0, ^4.151.3, ^5.9.3 …) and lockfile from `npm i -w bot`; dry-run + typecheck gates in later plans catch env/shape drift |
+| T-02-08 | DoS (supply chain) | bot/package.json deps | accept | Only 1 runtime dep (grammy) for a single-person project; versions pinned; lockfile committed; no paid/obscure packages |
+
+
+
+- `npm i` at repo root succeeds with the new bot workspace
+- `npm run bot:typecheck` exit 0
+- `npm test -w bot` runs the full harness and exits 1 with the RED suite visible (this is the expected, documented state at end of this plan — plan 02 flips it)
+- `git check-ignore bot/.dev.vars` → ignored
+
+
+
+- bot/ workspace scaffolded per D-09/D-10/D-11 with confirmed-clean typecheck and a working zero-dep test runner
+- Wave 0 complete per 02-VALIDATION.md: harness + vector suite skeleton exist and FAIL (RED) before any implementation exists
+- No secret values in the repo; `.dev.vars` ignored from the first commit
+
+
+
+After completion, create `.planning/phases/02-bot-skeleton-initdata-security/02-01-SUMMARY.md`
+
diff --git a/.planning/phases/02-bot-skeleton-initdata-security/02-02-PLAN.md b/.planning/phases/02-bot-skeleton-initdata-security/02-02-PLAN.md
new file mode 100644
index 0000000..8af5fc5
--- /dev/null
+++ b/.planning/phases/02-bot-skeleton-initdata-security/02-02-PLAN.md
@@ -0,0 +1,236 @@
+---
+phase: 02-bot-skeleton-initdata-security
+plan: 02
+type: execute
+wave: 2
+depends_on: ["02-01"]
+files_modified:
+ - bot/src/security/initdata.ts
+ - bot/src/kv/sessions.ts
+ - bot/test/sessions.test.mjs
+ - bot/test/privacy.test.mjs
+autonomous: true
+requirements: [PRIV-02]
+must_haves:
+ truths:
+ - "D-13: tampered, stale (auth_date older than ~15 min), and malformed initData are rejected with typed rejection classes; known-good HMAC passes (ROADMAP SC3 — the security gate)"
+ - "D-06: a validated identity mints exactly one live opaque token; D-07: re-mint rotates and deletes the previous token"
+ - "D-08/PRIV-02: every KV write carries an expirationTtl — no write path can persist without TTL; the claim is exactly user_id + chat_id + issued_at + language_code, never more"
+ - "D-11: rejection classes vocabulary (tampered/stale + expired-on-read) matches D-14's category-only log contract downstream"
+ artifacts:
+ - path: "bot/src/security/initdata.ts"
+ provides: "validateInitData + WebAppInitUser hand-rolled types (grammy does not ship them — research-verified)"
+ exports: ["validateInitData", "InitDataRejection"]
+ - path: "bot/src/kv/sessions.ts"
+ provides: "TTL-only KV access layer: mint/rotate/claim getters"
+ exports: ["SESSION_TTL_SECONDS", "mintSession", "getClaimByToken", "getTokenForChat"]
+ - path: "bot/test/sessions.test.mjs"
+ provides: "Rotate-on-new invariant + D-08 key-set-equality + TTL assertions with mock KV"
+ - path: "bot/test/privacy.test.mjs"
+ provides: "PRIV-02 architecture gate: every .put( line carries expirationTtl"
+ key_links:
+ - from: "bot/src/security/initdata.ts"
+ to: "npm test -w bot initdata suite"
+ via: "validateInitData consumes the plan-01 T1–T7 suite (GREEN here)"
+ pattern: "validateInitData"
+ - from: "bot/src/kv/sessions.ts"
+ to: "bot/src/security/initdata.ts"
+ via: "languageCode flows from validated initData into SessionClaim (D-08)"
+ pattern: "language_code"
+ - from: "bot/src/kv/sessions.ts"
+ to: "KVNamespace.put"
+ via: "every put call line includes expirationTtl: SESSION_TTL_SECONDS"
+ pattern: "expirationTtl"
+---
+
+
+Implement the phase's security exit criterion as pure, testable code: HMAC-SHA256 initData validation with rejection classes, and the TTL-only KV session layer with rotate-on-new — making the RED vector suite from plan 01 GREEN and adding the PRIV-02 storage gates.
+
+Purpose: "The initData validator is the first backend artifact" (ROADMAP goal) — provability comes from the vector suite, privacy-by-construction from the type-enforced TTL layer.
+Output: `src/security/initdata.ts` + `src/kv/sessions.ts`, both GREEN under `npm test -w bot` (`sessions` and `privacy` suites included).
+
+
+
+@$HOME/.config/opencode/get-shit-done/workflows/execute-plan.md
+@$HOME/.config/opencode/get-shit-done/templates/summary.md
+
+
+
+@.planning/phases/02-bot-skeleton-initdata-security/02-CONTEXT.md
+@.planning/phases/02-bot-skeleton-initdata-security/02-RESEARCH.md
+@.planning/phases/02-bot-skeleton-initdata-security/02-VALIDATION.md
+@.planning/phases/02-bot-skeleton-initdata-security/02-01-SUMMARY.md
+
+
+
+
+From bot/src/config.ts (plan 01):
+```typescript
+export interface Env {
+ BOT_TOKEN: string;
+ WEBHOOK_SECRET: string;
+ chat_sessions: KVNamespace;
+}
+```
+
+Test harness (plan 01): `import { test } from "./harness.mjs"` inside bot/test/*.test.mjs; runner auto-discovers every `*.test.mjs`.
+
+API this plan creates for plan 03 (bot/src/security/initdata.ts):
+```typescript
+export type InitDataRejection = "malformed" | "tampered" | "stale";
+
+export interface ParsedInitData {
+ user: { id: number; first_name?: string; username?: string; language_code?: string; [k: string]: unknown };
+ chat?: { id: number; type: string; [k: string]: unknown };
+ authDate: number; // seconds-since-epoch (Pitfall 3: never ms)
+ rawFields: Map; // decoded field values, `hash`/`signature` excluded
+}
+
+export interface WebAppInitUser { id: number; first_name?: string; last_name?: string; username?: string; language_code?: string; photo_url?: string; is_premium?: boolean; added_to_attachment_menu?: boolean; allows_write_to_pm?: boolean; } // hand-rolled — NOT shipped by grammy 1.46.0 (research-verified gap)
+
+export async function validateInitData(
+ raw: string,
+ botToken: string,
+ opts?: { nowSeconds?: number; maxAgeSeconds?: number },
+): Promise<
+ | { ok: true; user: WebAppInitUser; chat?: { id: number; type: string }; languageCode: string | null; authDate: number }
+ | { ok: false; reason: InitDataRejection }
+>;
+// defaults: maxAgeSeconds = 900 (D-13), nowSeconds = Math.floor(Date.now() / 1000)
+```
+
+API this plan creates for plan 03 (bot/src/kv/sessions.ts):
+```typescript
+export const SESSION_TTL_SECONDS = 900; // D-13 pairing — "minutes" envelope
+
+export interface SessionClaim {
+ user_id: number; // D-08: the entire claim surface — nothing else ever stored
+ chat_id: number;
+ issued_at: number; // seconds
+ language_code: string | null;
+}
+
+export async function mintSession(kv: KVNamespace, claim: SessionClaim): Promise;
+// mints crypto.randomUUID() token; rotates (D-07): reads chat: pointer,
+// deletes the previous `session:` key, then puts session: and chat: —
+// both puts ALWAYS carry { expirationTtl: SESSION_TTL_SECONDS } (PRIV-02, type-enforced by usage).
+
+export async function getTokenForChat(kv: KVNamespace, chatId: number): Promise;
+
+export async function getClaimByToken(kv: KVNamespace, token: string): Promise;
+// null = expired/unknown — callers treat as "expired" rejection class (D-14 vocabulary).
+```
+
+
+
+
+
+
+ task 1: validateInitData — HMAC core with rejection classes (D-13, D-14; GREEN the RED suite)
+ bot/src/security/initdata.ts
+
+ - .planning/phases/02-bot-skeleton-initdata-security/02-RESEARCH.md § Code Examples + § Common Pitfalls 1–3 (algorithm, key derivation direction, sorting)
+ - bot/test/initdata.test.mjs (the exact contract being GREENed — read before implementing)
+ - bot/src/config.ts (existing Env; initdata.ts must not import Env — it is pure (raw, botToken) by D-11)
+
+
+ Green conditions come from the plan 01 suite (T1–T7): known-good → ok + parsed user/languageCode; tampered hash → "tampered"; age 3600s → "stale"; 899s ok / 901s stale; out-of-order field insertion still OK; missing hash / NaN auth_date / user without id → "malformed"; RFC-4231 pin cross-check in-suite.
+
+
+Implement exactly per the RESEARCH.md-verified algorithm (no deviations; no third-party packages):
+1. **Parse:** split `raw` on `&` into `key=value` pairs; values URL-decode once (use `decodeURIComponent` on each value after split — `+` in initData is literal per Telegram's querystring encoding; do NOT use URLSearchParams because it converts `+` to space). Exclude both `hash` and `signature` from the check-string set but parse `hash`/`auth_date`/`user` normally when present. Keep insertion map (`rawFields`).
+2. **Data-check-string:** iterate field entries sorted **by field name** (default JS string sort — byte/case-sensitive), format `${key}=${value}`, join with `"\n"` (0x0A).
+3. **Key derivation (Pitfall 2 — direction):**
+ - secretKey bytes = HMAC-SHA256(message = UTF-8(botToken), key = UTF-8("WebAppData"))
+ - checkHex = hex(HMAC-SHA256(message = UTF-8(dataCheckString), key = secretKey))
+ Both via `crypto.subtle.importKey("raw", …, { name: "HMAC", hash: "SHA-256" }, false, ["sign"])` + `crypto.subtle.sign` (identical API in Workers + Node 24 — research-verified).
+4. **Rejection precedence and classes (D-14 vocabulary):** raw without `hash` → `malformed`; `user` absent/unparseable JSON or missing numeric `id` → `malformed`; `auth_date` absent/non-numeric → `malformed`; checkHex mismatch (timing-safe hex compare — compare via constant-time byte loop on UTF-8 bytes, mirroring grammY's shipped approach) → `tampered`; `nowSeconds - authDate > maxAgeSeconds` (default 900) → `stale`; else `{ ok: true, user, chat, languageCode: user.language_code ?? null, authDate }`.
+5. **Types:** hand-roll `WebAppInitUser`/`WebAppInitChat` in this file from Telegram's WebAppInitData spec (research gap resolution — grammy does NOT ship these, verified). Export `InitDataRejection`/`ParsedInitData` per `` so plan 03's endpoint has a typed surface.
+6. Erasable-only TS (plan 01 constraint). Module imports use explicit `.ts` extensions (`allowImportingTsExtensions` is on; no runtime imports needed here besides types — keep the file dependency-free of grammY so it stays testable without provider deps (D-11)).
+
+
+ npm test -w bot && npm run bot:typecheck
+
+
+ - `npm test -w bot` exits 0 with the initdata suite fully PASS (T1–T7 listed PASS) — RED suite is GREEN
+ - `bot/src/security/initdata.ts` contains `export async function validateInitData`, `InitDataRejection`, and the string `"WebAppData"` exactly once (the key derivation constant)
+ - No `import` of `grammy` inside `bot/src/security/initdata.ts` (D-11 isolation — grep returns 0)
+ - `npm run bot:typecheck` exits 0
+ - Sorting is by field name: suite T5 (out-of-order insertion) passes
+
+ The validator passes the pinned vector suite including tamper/stale/boundary/order/well-formed classes under SubtleCrypto (D-13 window wired via maxAgeSeconds default 900).
+
+
+
+ task 2: TTL-only KV sessions with rotate-on-new + PRIV-02 storage gates (D-06, D-07, D-08, D-14)
+ bot/src/kv/sessions.ts, bot/test/sessions.test.mjs, bot/test/privacy.test.mjs
+
+ - bot/src/security/initdata.ts (just written — languageCode flows into the claim; SessionClaim contract above)
+ - bot/test/harness.mjs + bot/test/runner.mjs (auto-discovery + `test()` API from plan 01)
+ - .planning/phases/02-bot-skeleton-initdata-security/02-RESEARCH.md § Pattern 3 (TTL-only KV access layer) + Open Questions resolution (rotate mechanics: delete-then-mint via chat pointer)
+
+
+1. **bot/src/kv/sessions.ts** per the `` contract:
+ - `SESSION_TTL_SECONDS = 900` (D-13 pairing — a session cannot outlive the freshness proof that minted it; "minutes" envelope per CONTEXT).
+ - Key scheme: claim record under `session:`; per-chat pointer under `chat:` holding the current token string. Both keys ALWAYS written with `{ expirationTtl: SESSION_TTL_SECONDS }`.
+ - `mintSession(kv, claim)`: `token = crypto.randomUUID()` (opaque by construction, D-06); read `chat:` → oldToken; `kv.delete("session:" + oldToken)` if found (D-07 rotate-on-new — the structural close of the recent-stale-replay mint window); `kv.put("session:" + token, JSON.stringify(claim), { expirationTtl: SESSION_TTL_SECONDS })`; `kv.put("chat:" + claim.chat_id, token, { expirationTtl: SESSION_TTL_SECONDS })`; return token.
+ - `getTokenForChat(kv, chatId)`: read pointer; `getClaimByToken(kv, token)`: read record, JSON.parse, return or null. Reads never write.
+2. **bot/test/sessions.test.mjs** — in-memory 20-line KV fake (Map-backed KVNamespace subset: `get`, `put(key, value, opts)` asserting `opts.expirationTtl` is present and finite, `delete`, plus a `ttlLog` of writes):
+ - mints return a token whose record contains EXACTLY the D-08 keys `{user_id, chat_id, issued_at, language_code}` (assert key-set equality — no drift toward profiles)
+ - every `put` recorded in ttlLog carries expirationTtl === 900 (PRIV-02 by construction)
+ - rotate: mint twice for same chat → older `session:` key is gone, pointer target is tok2, exactly one live session record (D-07 invariant)
+ - `getClaimByToken` round-trips; token unknown → null (expired-path class, D-14)
+ - claim survives with `language_code: "ms"` and null both
+3. **bot/test/privacy.test.mjs** — PRIV-02 architecture gate, zero-KV:
+ - Read `src/kv/sessions.ts` + `src/webhook.ts` (latter may not exist yet — filter existence) and assert every `.put(` occurrence on any line also contains `expirationTtl` on the same line (grep gate: no TTL-free write path can ship)
+ - Assert `bot/src/kv/sessions.ts` exports nothing named like persistence (e.g., `saveProfile`, `storeLocation` do not exist)
+
+
+ npm test -w bot && npm run bot:typecheck && grep -cn "expirationTtl" bot/src/kv/sessions.ts | head -1
+
+
+ - `npm test -w bot` exits 0 — three suites PASS (initdata, sessions, privacy)
+ - `sessions.test.mjs` contains a case named like `rotate` / D-07 that asserts the old `session:` key no longer exists after re-mint
+ - Every KV write in `src/kv/sessions.ts` occurs with `{ expirationTtl: SESSION_TTL_SECONDS }` visible on the call line (privacy suite enforces)
+ - `SessionClaim` interface contains only `user_id`, `chat_id`, `issued_at`, `language_code` (D-08 — grep the file for a 5th property: none)
+ - `npm run bot:typecheck` exits 0
+
+ Opaque tokens mint only alongside TTL-expiring claims, rotate-on-new invalidates the previous token, PRIV-02 gates are in the automated suite (this closes the plan-01→02 dependency: full GREEN).
+
+
+
+
+
+## Trust Boundaries
+
+| Boundary | Description |
+|----------|-------------|
+| client → /api/session entry (built in plan 03) | initData arrives untrusted; this plan defines the ONLY accepted path through validation |
+| KV writes → durable storage | any schema-less KV write could persist PII indefinitely |
+
+## STRIDE Threat Register
+
+| Threat ID | Category | Component | Disposition | Mitigation Plan |
+|-----------|----------|-----------|-------------|-----------------|
+| T-02-01 | Tampering / Spoofing (forged initData) | validateInitData | mitigate | HMAC-SHA256 over data-check-string with derived secret key (Telegram-verified algorithm); constant-time hex compare |
+| T-02-02 | Replay of stale-but-valid hash | sessions.ts + validateInitData(900s) | mitigate | auth_date freshness 900s (D-13) + rotate-on-new invalidates any token minted from a replay (D-07); suite pins both |
+| T-02-05 | Spoofing (token prediction) | mintSession | mitigate | crypto.randomUUID() — 122-bit RNG, not Math.random |
+| T-02-06 | Information Disclosure (persistent claim drift) | sessions.ts | mitigate | D-08 key-set-equality test + same-line expirationTtl grep gate in privacy.test.mjs |
+
+
+
+- `npm test -w bot` exit 0 (three suites green: initdata + sessions + privacy)
+- `npm run bot:typecheck` exit 0
+- grep gate: every `.put(` in src/kv/sessions.ts carries `expirationTtl` on its line
+- No grammy import inside src/security/initdata.ts or src/kv/sessions.ts (isolation per D-11)
+
+
+
+- Security exit criterion (ROADMAP SC3 logic): tampered/stale/malformed initData vectors all rejected; known-good HMAC passes — in automated CI-style suite
+- Privacy by construction (ROADMAP SC4 logic): TTL-only namespace written without exception; claim is exactly D-08-minimal; rotate-on-new holds the one-live-token-per-chat invariant
+- All `bots` suites pass without network, account, or Provider dependencies (pure Node 24)
+
+
+
+After completion, create `.planning/phases/02-bot-skeleton-initdata-security/02-02-SUMMARY.md`
+
diff --git a/.planning/phases/02-bot-skeleton-initdata-security/02-03-PLAN.md b/.planning/phases/02-bot-skeleton-initdata-security/02-03-PLAN.md
new file mode 100644
index 0000000..3a7d7bc
--- /dev/null
+++ b/.planning/phases/02-bot-skeleton-initdata-security/02-03-PLAN.md
@@ -0,0 +1,299 @@
+---
+phase: 02-bot-skeleton-initdata-security
+plan: 03
+type: execute
+wave: 3
+depends_on: ["02-01", "02-02"]
+files_modified:
+ - bot/src/i18n/strings.ts
+ - bot/src/bot/bot.ts
+ - bot/src/bot/commands/start.ts
+ - bot/src/bot/commands/help.ts
+ - bot/src/bot/commands/privacy.ts
+ - bot/src/webhook.ts
+ - bot/scripts/register-commands.mjs
+ - bot/scripts/set-webhook.mjs
+ - bot/test/commands.test.mjs
+ - bot/wrangler.jsonc
+autonomous: false
+requirements: [ONBD-01, PRIV-01]
+must_haves:
+ truths:
+ - "D-01/D-02/D-04: a real Telegram user sends /start (or /mula) and receives one honest clinical sentence with no buttons/inline keyboards"
+ - "D-03/D-04: the bot's command menu lists exactly /help and /privacy via setMyCommands in default + ms scopes — nothing else, no stub future entries"
+ - "D-01/D-05/D-17: /privacy states stored-facts and never-facts in en + ms that match the code's actual KV writes (PRIV-01)"
+ - "D-05: unknown Telegram locales fall back to English; ms-prefixed locales get the Malay surface"
+ - "D-14/D-16/D-17: webhook 401s without the secret-token header; only validated initData identities obtain a one-time token; rejection logs are category-only; deploy is local wrangler + setWebhook"
+ artifacts:
+ - path: "bot/src/i18n/strings.ts"
+ provides: "Single copy source of truth (en + ms with identical key-sets)"
+ contains: "pickLocale"
+ - path: "bot/src/webhook.ts"
+ provides: "Worker entry: /telegram/webhook + /api/session security gate + D-14 category-only logs"
+ contains: "cloudflare-mod"
+ - path: "bot/scripts/register-commands.mjs"
+ provides: "One-time setMyCommands (default + ms scope — D-03/D-04)"
+ contains: "setMyCommands"
+ - path: "bot/scripts/set-webhook.mjs"
+ provides: "setWebhook with secret_token (D-16 deploy scripting)"
+ contains: "secret_token"
+ key_links:
+ - from: "bot/src/webhook.ts"
+ to: "bot/src/security/initdata.ts"
+ via: "handleSession → validateInitData → 401 on rejection / mint on success"
+ pattern: "validateInitData"
+ - from: "bot/src/webhook.ts"
+ to: "bot/src/kv/sessions.ts"
+ via: "mintSession called ONLY after ok:true (one mint path — PRIV-02)"
+ pattern: "mintSession"
+ - from: "bot/src/webhook.ts"
+ to: "bot/src/bot/bot.ts"
+ via: "webhookCallback(bot, 'cloudflare-mod', secretToken=env.WEBHOOK_SECRET)"
+ pattern: "webhookCallback"
+ - from: "bot/src/i18n/strings.ts"
+ to: "bot/src/bot/commands/*.ts"
+ via: "handlers import copy from strings (no inline copy — D-05)"
+ pattern: "strings"
+user_setup:
+ - service: telegram-bot
+ why: "Real BOT_TOKEN — webhook validation, session minting, and command-menu registration all authenticate with the bot token from @BotFather"
+ env_vars:
+ - name: BOT_TOKEN
+ source: "Telegram @BotFather -> /newbot -> telegram -> copy token"
+ dashboard_config:
+ - task: "Keep token secret; paste into bot/.dev.vars (never commit)"
+ location: "local file bot/.dev.vars (D-15)"
+ - service: cloudflare
+ why: "Workers deploy target + KV namespace chat_sessions (D-16 deploy gate)"
+ env_vars:
+ - name: CLOUDFLARE_API_TOKEN (or automated wrangler login)
+ source: "Cloudflare Dashboard -> My Profile -> API Tokens (or `npx wrangler login` browser flow)"
+---
+
+
+Build the user-facing Telegram surface (commands + honest privacy copy + Malay aliases) and wire the Worker: webhook route with secret-token auth, `/api/session` security gate minting tokens, command-menu registration, deploy plumbing — then pause for the human deploy gate (D-16) that proves the whole phase in a real client.
+
+Purpose: ROADMAP SC1/SC2 (visible command surface, honest /privacy) become real bot behavior; the tested security core becomes reachable (`/api/session` is the phase's security-gate entry).
+Output: runtime-complete bot + wired worker + one-time registration scripts; deploy checkpoint for ONBD-01.
+
+
+
+@$HOME/.config/opencode/get-shit-done/workflows/execute-plan.md
+@$HOME/.config/opencode/get-shit-done/templates/summary.md
+
+
+
+@.planning/phases/02-bot-skeleton-initdata-security/02-CONTEXT.md
+@.planning/phases/02-bot-skeleton-initdata-security/02-RESEARCH.md
+@.planning/phases/02-bot-skeleton-initdata-security/02-VALIDATION.md
+@.planning/phases/02-bot-skeleton-initdata-security/02-01-SUMMARY.md
+@.planning/phases/02-bot-skeleton-initdata-security/02-02-SUMMARY.md
+
+
+
+
+From bot/src/config.ts: `interface Env { BOT_TOKEN: string; WEBHOOK_SECRET: string; chat_sessions: KVNamespace }`
+
+From bot/src/security/initdata.ts (plan 02):
+```typescript
+export type InitDataRejection = "malformed" | "tampered" | "stale";
+export async function validateInitData(raw: string, botToken: string,
+ opts?: { nowSeconds?: number; maxAgeSeconds?: number }): Promise<{ ok: true; user: {id: number; language_code?: string}; chat?: {id: number; type: string}; languageCode: string | null; authDate: number } | { ok: false; reason: InitDataRejection }>;
+```
+
+From bot/src/kv/sessions.ts (plan 02):
+```typescript
+export const SESSION_TTL_SECONDS = 900;
+export interface SessionClaim { user_id: number; chat_id: number; issued_at: number; language_code: string | null; }
+export async function mintSession(kv: KVNamespace, claim: SessionClaim): Promise;
+```
+
+grammY webhook adapter (plan 02 RESEARCH verified): `webhookCallback(bot, "cloudflare-mod", onTimeout, timeoutMilliseconds, secretToken)` from `"grammy"` — constant-time X-Telegram-Bot-Api-Secret-Token check whose config is this plan's `env.WEBHOOK_SECRET`.
+
+
+
+
+
+
+ task 1: strings module + command surface with aliases (D-01, D-02, D-03, D-04, D-05)
+ bot/src/i18n/strings.ts, bot/src/bot/commands/start.ts, bot/src/bot/commands/help.ts, bot/src/bot/commands/privacy.ts, bot/src/bot/bot.ts, bot/test/commands.test.mjs
+
+ - .planning/phases/02-bot-skeleton-initdata-security/02-CONTEXT.md (D-01..D-05 verbatim + "strings module is the copy's tokens.css")
+ - .planning/phases/02-bot-skeleton-initdata-security/02-RESEARCH.md § Code Examples (setMyCommands localized scopes — grammar verified)
+ - bot/src/kv/sessions.ts (SESSION_TTL_SECONDS / claim fields — the copy's "what is stored" facts must match this code, D-17)
+ - bot/test/harness.mjs (test API)
+
+
+1. **bot/src/i18n/strings.ts** — single copy source of truth (D-05), en default + `ms` (Bahasa Melayu), clinical + honest tone (D-01, no emoji, no marketing). Keys (identical key-set in both locales — test asserts):
+ ```typescript
+ export interface BotStrings {
+ startIntro: string; // D-01: what araharah is, one sentence
+ privacyStored: string; // exactly what is stored — must name: Telegram user id, chat id, language, kept minutes (claim fields from sessions.ts)
+ privacyNotStored: string; // exactly what is NOT stored — must name: location, routes, trip history, no profile (D-17: copy verifiable against code)
+ helpMenu: string; // lists only /help and /privacy (D-03 — no stub entries for future commands)
+ }
+ export type Locale = "en" | "ms";
+ export const strings: Record = { en: {…}, ms: {…} };
+ export function pickLocale(telegramLanguageCode?: string | null): Locale;
+ // "ms" prefix match → "ms"; every other value (incl. null/undefined) → "en" (D-05 fallback rule)
+ ```
+ Concrete copy (use as written; MS kept parallel in meaning):
+ - en.startIntro: `"araharah — navigation through Telegram for the KL metro. Everything it knows about you expires in about fifteen minutes; /privacy shows exactly what that means."`
+ - en.privacyStored: `"What araharah keeps: your Telegram user id, this chat id, and your interface language — kept only for about 15 minutes so the bot can answer you, then gone."`
+ - en.privacyNotStored: `"What araharah never keeps: your location, your routes, your trip history, any profile about you."`
+ - en.helpMenu: Commands `/help …` and `/privacy …` descriptions (matches the setMyCommands registry — D-03; /help itself is a live command this phase so listing it is honest).
+ - ms.startIntro: `"araharah — navigasi melalui Telegram untuk kawasan KL. Semua yang diketahui tentang anda luput dalam lebih kurang lima belas minit; /privacy menunjukkan dengan tepat maksudnya."`
+ - ms.privacyStored: `"Yang araharah simpan: id pengguna Telegram anda, id chat ini, dan bahasa antara muka anda — disimpan hanya sekitar 15 minit supaya bot boleh menjawab anda, kemudian hilang."`
+ - ms.privacyNotStored: `"Yang araharah tidak pernah simpan: lokasi anda, laluan anda, sejarah perjalanan anda, apa-apa profil tentang anda."`
+ - ms.helpMenu: parallel to en.
+2. **Commands as pure builders + thin grammar wiring (testable without a Bot instance):** each command module exports `replyText(kind, locale)` pure functions and the grammy handler that calls them. Handlers use **plain text-only replies** — `ctx.reply(text, { link_preview_options: { is_disabled: true } })` and NOTHING else (D-02: no reply keyboard, no inline buttons; the structural assertion lives in the test).
+ - `start.ts`: exports handler for both `/start` and `/mula` (aliases on the same command stack, D-04)
+ - `help.ts`: `/help` handler (no alias — D-04 names later-phase aliases only)
+ - `privacy.ts`: handlers for `/privacy` and `/privasi` (D-04); reply = privacyStored + "\n\n" + privacyNotStored
+3. **bot/src/bot/bot.ts:** `createBot(env: Env): Bot` — instantiates `new Bot(env.BOT_TOKEN)`, registers handlers above (alias forms via `bot.command("mula", …)` etc.), and **sets NO keyboards, NO inline buttons anywhere** (D-02). Menu registration lives in the one-time script (next item), not per-request.
+4. **bot/scripts/register-commands.mjs** (one-time; D-03/D-04 — node, zero deps beyond `node:fs`):
+ - Reads `BOT_TOKEN` from `bot/.dev.vars` (parse `KEY=VALUE` lines)
+ - `fetch` to `https://api.telegram.org/bot/setMyCommands` twice:
+ - default scope: `{ "commands": [ {command:"help", desc-en}, {command:"privacy", desc-en} ], "bot_command_scope": { "type": "default" } }`
+ - `ms` scope: same two commands with Malay descriptions + `"language_code": "ms"`
+ - Exits 0 on `ok:true`; prints the JSON response; no other commands registered (D-03 — no stub entries for future commands like /cari)
+5. **bot/test/commands.test.mjs:**
+ - strings integrity: en/ms key-sets identical (assert key arrays sort-equal); `pickLocale("ms-MY") → "ms"`, `pickLocale("en") → "en"`, `pickLocale(null/undefined/\"id\") → "en"`
+ - PRIV-01 copy gate: `strings.en.privacyNotStored` + `ms.privacyNotStored` mention location, routes, history; `privacyStored` mentions the TTL minutes wording
+ - D-02 structural: the bot module source contains no `"keyboard"` or `"inline_keyboard"`/`InlineKeyboard` token (grep gate on bot/src/bot/bot.ts + commands files)
+ - helpMenu text matches the two descriptions passed to register-commands (gap-free D-03)
+
+
+ npm test -w bot && npm run bot:typecheck && node --check bot/scripts/register-commands.mjs
+
+
+ - `bot/src/i18n/strings.ts` exports `strings` (Record<"en"|"ms", BotStrings>) and `pickLocale`, with identical key-sets across locales (suite asserts)
+ - commands.test.mjs asserts privacyNotStored text mentions location + routes + history (PRIV-01 gate) and privacyStored mentions 15 minutes
+ - bot/src/bot/bot.ts + command files grep: zero occurrences of `keyboard` / `inline_keyboard` (D-02)
+ - register-commands.mjs contains exactly two fetch calls to `setMyCommands`, one with `"language_code": "ms"`, one without; command lists contain only `help` and `privacy` (D-04)
+ - `npm test -w bot` exit 0, `npm run bot:typecheck` exit 0
+
+ Copy lives only in strings.ts (en+ms); /start /help /privacy + /mula /privasi handlers wired text-only; menu registration script ready with honest D-03 scope.
+
+
+
+ task 2: Worker entry — webhook route, /api/session security gate, startWebhook plumbing (D-14, D-16, D-17)
+ bot/src/webhook.ts, bot/scripts/set-webhook.mjs, bot/wrangler.jsonc
+
+ - bot/src/webhook.ts is new; read bot/src/bot/bot.ts, src/security/initdata.ts, src/kv/sessions.ts (contracts in )
+ - .planning/phases/02-bot-skeleton-initdata-security/02-RESEARCH.md § Pattern 1 (real webhookCallback signature — verify before wiring) + § RESEARCH.md Architecture diagram
+ - bot/wrangler.jsonc (created in plan 01 — KV_ID_PLACEHOLDER remains until task 3 checklist fills it)
+
+
+1. **bot/src/webhook.ts** (the D-11 "worker entry"):
+ ```typescript
+ export default {
+ async fetch(req: Request, env: Env, ctx: ExecutionContext): Promise {
+ const url = new URL(req.url);
+ if (url.pathname === "/telegram/webhook") {
+ const bot = createBot(env); // env-bound token → per-request instantiation (research-verified pattern)
+ const cb = webhookCallback(bot, "cloudflare-mod", undefined, undefined, env.WEBHOOK_SECRET);
+ return cb(req);
+ }
+ if (url.pathname === "/api/session") { // Phase 2 security gate — Mini App caller arrives Phase 4
+ return handleSession(req, env);
+ }
+ return new Response("Not found", { status: 404 });
+ },
+ } satisfies ExportedHandler;
+
+ async function handleSession(req: Request, env: Env): Promise {
+ if (req.method !== "POST") return new Response("Method not allowed", { status: 405 });
+ const raw = await req.text(); // the verbatim Telegram.WebApp.initData string
+ const result = await validateInitData(raw, env.BOT_TOKEN);
+ if (!result.ok) {
+ console.log(`[initdata] rejected: ${result.reason}`); // D-14 category-only — NO raw payload, NO user ids, NO timestamps
+ return new Response(JSON.stringify({ error: result.reason }), { status: 401, headers: { "content-type": "application/json" } });
+ }
+ const token = await mintSession(env.chat_sessions, {
+ user_id: result.user.id,
+ chat_id: result.chat?.id ?? result.user.id, // private chat: Telegram conflates; claim surface unchanged (D-08)
+ issued_at: Math.floor(Date.now() / 1000),
+ language_code: result.languageCode,
+ });
+ return new Response(JSON.stringify({ token }), { status: 200, headers: { "content-type": "application/json" } });
+ }
+ ```
+ (Replay: a repeated valid initData rotates through mintSession — plan 02's D-07 invariant closes it; the endpoint adds no extra branch.)
+2. **bot/scripts/set-webhook.mjs:** one-time; reads BOT_TOKEN + WEBHOOK_URL + WEBHOOK_SECRET from `bot/.dev.vars`; `fetch` `https://api.telegram.org/bot/setWebhook` with `{ "url": WEBHOOK_URL, "secret_token": WEBHOOK_SECRET, "allowed_updates": ["message"] }`; prints result; exit 0 only on `ok:true` (D-16).
+3. **bot/package.json scripts (edit — no new file):** `"deploy": "wrangler deploy && node scripts/set-webhook.mjs"` (D-16: deploy + webhook in one command), keeping `commands`, `dev`, `test`, `typecheck` from plan 01.
+4. **bot/wrangler.jsonc:** add `"workers_dev": true` comment marker only; leave `KV_ID_PLACEHOLDER` for task 3 (deployment checklist fills it).
+
+
+ npm test -w bot && npm run bot:typecheck && (cd bot && npx wrangler deploy --dry-run --outdir /tmp/opencode/worker-dryrun)
+
+
+ - `bot/src/webhook.ts` routes exactly `/telegram/webhook` and `/api/session` (dry-run bundle accepts `satisfies ExportedHandler`)
+ - The only initdata-related log statement is category-only: grep `bot/src/webhook.ts` for `console.log` statements each matching `\`[initdata] rejected: ${…`} — no payload/ids logging (D-14)
+ - `set-webhook.mjs` passes `secret_token` = WEBHOOK_SECRET into setWebhook payload (verified secret-token flow)
+ - `wrangler deploy --dry-run` exits 0 (no account credentials needed — local esbuild only)
+ - `npm run bot:typecheck` + `npm test -w bot` exit 0 (existing suites unbroken)
+
+ Worker builds for deploy; security gate reachable at /api/session; webhook auth + D-14 category logging in place; nothing secret leaves `env` reading.
+
+
+
+ task 3: deploy gate — real-client walkthrough of the full command surface (D-16, ONBD-01)
+ Automate everything possible first (credentials aside): wrangler kv namespace create + fill id, `npm run bot:deploy` (wrangler deploy + setWebhook), `npm run bot:commands`, KV key spot-check — then walk the user through the numbered real-client verification in . Human-only steps: @BotFather token, wrangler login/browser auth, Telegram client walkthrough (a–e).
+
+ The complete Phase 2 bot: grammY bot with /start /help /privacy (+ /mula /privasi aliases), honest RAMS-tone privacy copy (en + ms), TTL-only KV sessions behind the /api/session initData security gate, secret-token-protected webhook, all under CI test vectors (green suites). All backend code paths are automated-verified; what remains needs a real Telegram client, a real bot token, and a real Worker deploy (D-16: no CI yet — human deploy gate).
+
+
+ OpenCode automates everything below EXCEPT the credential steps and the real-client walkthrough; run these in order:
+ 1. Credentials (human-only): create bot via @BotFather → copy token into `bot/.dev.vars` as `BOT_TOKEN=...`; generate `WEBHOOK_SECRET=$(openssl rand -hex 32)` and add to `bot/.dev.vars`; authenticate Cloudflare (`npx wrangler login` or CLOUDFLARE_API_TOKEN).
+ 2. OpenCode runs: `npx wrangler kv namespace create chat_sessions` → copy returned id into `bot/wrangler.jsonc` replacing `KV_ID_PLACEHOLDER`; set `WEBHOOK_URL` in `.dev.vars` to the printed workers.dev URL after `npm run bot:deploy` (deploy + setWebhook via scripts).
+ 3. OpenCode runs `npm run bot:commands` (registers /help /privacy default + ms scopes).
+ 4. Real-client walkthrough (human, this checkpoint): in Telegram against your own bot —
+ a. send `/start` → one sentence intro, NO buttons (D-01/D-02)
+ b. open the command menu (menu button) → ONLY /help and /privacy listed (D-03)
+ c. `/privacy` → stored-facts block (user id, chat id, language, ~15 min) + never-block (location, routes, history, profile)
+ d. `/mula` behaves exactly like /start; `/privasi` like /privacy
+ e. send gibberish `/xyz` → no crash, no menu drift
+ 5. Storage spot-check (PRIV-02 work-around): OpenCode runs `npx wrangler kv key list` after a manual /start — only `session:*`/`chat:*` keys, TTL-bounded, no coordinates; keys vanish after ~15 min.
+
+ Type "approved" or describe issues — deploy gate per ROADMAP SC1–SC4 (ONBD-01, PRIV-01, PRIV-02) must all be observed before phase exit.
+
+
+
+
+
+## Trust Boundaries
+
+| Boundary | Description |
+|----------|-------------|
+| Telegram servers → webhook endpoint | updates arrive over the public internet; secret-token header is the gatekeeper |
+| Mini App client → /api/session | initData arrives untrusted from the user's webview |
+| Worker logs → Cloudflare log stream | anything logged becomes visible outside the app |
+
+## STRIDE Threat Register
+
+| Threat ID | Category | Component | Disposition | Mitigation Plan |
+|-----------|----------|-----------|-------------|-----------------|
+| T-02-03 | DoS / webhook flooding | webhook route | mitigate | grammY's verified constant-time secret-token compare 401s garbage BEFORE bot processing; no KV touched pre-check |
+| T-02-01 | Spoofing (forged identity → token) | /api/session | mitigate | Only validateInitData→mintSession path mints; rejection classes log category-only, return 401s |
+| T-02-02 | Replay of stale-but-valid initData | /api/session | mitigate | 900s freshness window + D-07 rotate via plan-02 mintSession |
+| T-02-04 | Information Disclosure (logs, SSRF/repo) | webhook.ts, scripts/* | mitigate | D-14 category logs; secrets read from .dev.vars/ secrets at runtime only; scripts never echo the bot token (grep-gate: no `console.log` of BOT_TOKEN/secret) |
+| T-02-06 | Information Disclosure (KV persistence drift) | webhook.ts | mitigate | only mintSession writes KV (TTL-enforced by plan-02 suite); endpoint adds no storage |
+
+
+
+- `npm test -w bot` + `npm run bot:typecheck` exit 0 (all 4 suites)
+- `wrangler deploy --dry-run` exit 0
+- Manual checkpoint observes SC1–SC3 in a real client before phase exit (D-16 deploy gate; autonomous: false precisely for this)
+- Storage spot-check shows TTL-only KV keys (SC4)
+
+
+
+- Real Telegram client: /start concise intro text-only; command menu lists exactly /help and /privacy; /privacy shows stored/never-stored truth; Malay aliases co-route; en fallback for unknown locales
+- Deployed Worker rejects unauthorized webhook calls (secret-token) and unvalidated session requests (rejection classes), mints one-token-per-chat for validated identities
+- KV contains only TTL-bounded session/pointer keys — no persistent coordinates or profiles
+
+
+
+After completion, create `.planning/phases/02-bot-skeleton-initdata-security/02-03-SUMMARY.md`
+
diff --git a/.planning/phases/02-bot-skeleton-initdata-security/02-CONTEXT.md b/.planning/phases/02-bot-skeleton-initdata-security/02-CONTEXT.md
new file mode 100644
index 0000000..e0a55bb
--- /dev/null
+++ b/.planning/phases/02-bot-skeleton-initdata-security/02-CONTEXT.md
@@ -0,0 +1,111 @@
+# Phase 2: Bot Skeleton + initData Security - Context
+
+**Gathered:** 2026-10-22
+**Status:** Ready for planning
+
+
+## Phase Boundary
+
+Users can reach araharah through Telegram with a working command surface (/start, /help, /privacy, plus Malay aliases) served by a grammY bot on one Cloudflare Worker, and the backend's identity + privacy architecture is provably sound from the first deploy: HMAC-validated Telegram WebApp initData with CI test vectors, one-time opaque session tokens in TTL-only KV (`chat_sessions`). Mini App shell, search, and the `guidance_sessions` envelope design are later phases.
+
+
+
+
+## Implementation Decisions
+
+### Bot copy + interaction style
+- **D-01:** Tone is clinical + honest (Rams-aligned): what araharah is in one sentence, no emoji clutter, no marketing flourish. Privacy statement states what is NOT stored as plainly as what is.
+- **D-02:** /start is a plain text-only message — no reply keyboard, no inline buttons. Telegram commands are the interface (the Mini App launch path is Phase 4's decision).
+- **D-03:** Permanent command menu contains only commands that exist in this phase (/help, /privacy) registered via setMyCommands — no stub entries for future commands.
+- **D-04:** Malay command aliases are first-class citizens of the surface from this phase: `/mula` runs the same flow as `/start`, `/privasi` as `/privacy`. Later flow aliases are named now and implemented by their phases: `/pergi_ke` (~ goto), `/cari` (~ search, Phase 4). setMyCommands registers localized command descriptions via a `ms` language_code scope in addition to the default.
+
+### Copy localization
+- **D-05:** All bot copy lives in a strings module (`src/i18n/strings.ts`): English default + Bahasa Melayu `ms` locale. Telegram's per-user `language_code` picks the locale; unhandled codes fall back to English. Command aliases are handlers on the same command stack, not separate flows.
+
+### Session token mechanics
+- **D-06:** Session tokens are opaque random token ids stored in KV `chat_sessions` alongside a minimal bootstrap claim. Not JWTs — revocable and audit-friendly. Handing out the token is the DSGN-equivalent security gate: only successful initData validation mints one.
+- **D-07:** Rotate-on-new: when a validated identity arrives for a chat that already holds a live session token, the old token is invalidated (overwritten). One live token per chat. The recent-stale-replay risk that a replicated validation could mint extra tokens is thereby closed structurally.
+- **D-08:** The stored claim is deliberately minimal: Telegram `user.id`, `chat.id`, `issued_at`, and the user's `language_code` (needed to route the bot's future chat cards in the user's locale). No location, no routes, no profile.
+
+### Backend repo layout
+- **D-09:** Backend lives in a `bot/` workspace sibling to `theme/` (npm workspaces, root package.json gets thin delegate scripts, mirroring the Phase 1 pattern).
+- **D-10:** Language is TypeScript for everything backend (grammY typings first-class, `@cloudflare/workers-types` for the runtime).
+- **D-11:** bot/ layout is an explicit `src/` tree: `src/webhook.ts` (worker entry), `src/security/initdata.ts` (HMAC validation — isolated, testable without provider dependencies), `src/kv/sessions.ts` (TTL-only access layer), `src/i18n/strings.ts`, `src/bot/commands/{start,help,privacy}.ts`, wrangler config colocated.
+
+### Validation + test harness
+- **D-12:** Test runner is a hand-rolled node runner in the same shape as `theme/scripts/audit.test.mjs` (Phase 1's pattern) — no extra test dependency for this pure-Crypto HMAC vector suite.
+- **D-13:** initData freshness policy: reject payloads whose `auth_date` is older than ~15 minutes; the Mini App re-validates with fresh initData on launch. This pairs with the `chat_sessions` minutes-TTL envelope.
+- **D-14:** Rejection logging is category-only: tampered / stale / replayed / expired — never the raw payload, never coordinates, no user-marking timestamps. Satisfies the "logs carry no coordinate data" criterion from day one.
+
+### Secrets + deploy setup
+- **D-15:** Secrets (bot token, HMAC secret) live in Cloudflare Wrangler secrets. Local dev mirrors via a gitignored `.dev.vars` (only `.dev.vars.example` ships in the repo).
+- **D-16:** Phase 2 deploy is local `npm run deploy` (wrangler deploy + setWebhook). No CI workflow yet — it arrives in Phase 3 with real Mini App users.
+- **D-17:** Strict secret hygiene: no secret value ever committed, webhook URL in wrangler config, and the /privacy copy stays verifiable by reading the code's storage calls — the architecture can't hold more than it says it does.
+
+### OpenCode's Discretion
+- Exact setMyCommands scope/locale structuring details
+- Webhook secret token mechanics (Telegram's `secret_token` param) at the researcher's discretion
+- Exact KV TTL values inside the "minutes" envelope
+- How local wrangler dev simulates KV for testing the product loop
+- How the researcher verifies exact grammY initData typings (flagged research gap)
+
+
+
+
+## Specific Ideas
+
+- Command menu and bot surface should feel like the Rams product inside Telegram: spare, honest, functional. /privacy is a promise visibly kept, not a legal disclaimer.
+- Malay aliases are the user's explicit addition — locals should be able to drive in their own language from day one.
+- Single-file source of truth predilection carries over: the strings module is the copy's tokens.css — no copy pasted inline in handlers.
+
+
+
+
+## Canonical References
+
+**Downstream agents MUST read these before planning or implementing.**
+
+### Phase scope + decisions
+- `.planning/ROADMAP.md` §Phase 2 — goal, 4 success criteria, notes (KV namespace, grammY research gap)
+- `.planning/REQUIREMENTS.md` — ONBD-01, PRIV-01, PRIV-02 (this phase's requirements)
+- `.planning/phases/01-design-tokens-rams-foundation/01-CONTEXT.md` — Phase 1 pattern precedent (workspace structure, hand-rolled test runner, TDD discipline established)
+
+### Codebase
+- `.planning/codebase/CONCERNS.md`, `STACK.md`, `ARCHITECTURE.md` — pre-existing repo caveats (secret handling from day one)
+- `AGENTS.md` — coding conventions
+
+
+
+
+## Existing Code Insights
+
+### Reusable Assets
+- `theme/` npm workspace pattern (root delegates + per-workspace package.json + scripts) — the repo's established structure for every subsequent workspace
+- `theme/scripts/audit.test.mjs` — hand-rolled test runner pattern: node:assert, minimal, no deps (D-12 builds on this shape)
+- Package-layout precedent: isolated concerns in their own module files (e.g. tokens/components/audit separation)
+
+### Established Patterns
+- TDD discipline from Phase 1 (RED→GREEN commits where sensible)
+- Type-only imports, zero-radius kebab-case vocabulary, strict gitignored `.planning/`
+- npm workspaces with self-hosted assets — same mental model applies to backend secrets
+
+### Integration Points
+- `theme/dist/rams.css` + basemap style become Mini App static assets served by the same Worker in Phase 3 — Phase 2's worker must implicitly plan where static asset binding lands (researcher's structural hint, not Phase 2 implementation)
+- `chat_sessions` KV namespace is the first durable namespace — its TTL/shape is what Phase 5's envelope design extends
+
+
+
+
+## Deferred Ideas
+
+- Webhook dispatch via static-asset router to inline Telegram Mini App serving (Phase 3 shapes the mixed static+api worker, not this phase)
+- Metrics/diagnostics beyond category logs (Phase 7 audit scope decides)
+
+None — discussion stayed within phase scope.
+
+
+
+---
+
+*Phase: 02-bot-skeleton-initdata-security*
+*Context gathered: 2026-10-22*
diff --git a/.planning/phases/02-bot-skeleton-initdata-security/02-DISCUSSION-LOG.md b/.planning/phases/02-bot-skeleton-initdata-security/02-DISCUSSION-LOG.md
new file mode 100644
index 0000000..4f337a3
--- /dev/null
+++ b/.planning/phases/02-bot-skeleton-initdata-security/02-DISCUSSION-LOG.md
@@ -0,0 +1,58 @@
+# Phase 2: Bot Skeleton + initData Security - Discussion Log
+
+> **Audit trail only.** Do not use as input to planning, research, or execution agents.
+> Decisions captured in CONTEXT.md — this log preserves the analysis.
+
+**Date:** 2026-10-22
+**Phase:** 02-bot-skeleton-initdata-security
+**Mode:** default (interactive)
+
+## Areas Discussed
+
+### Bot copy + interaction style
+| Question | Options presented | Selection |
+|---|---|---|
+| Tone | Clinical + honest / Friendly + light / Formal | Clinical + honest |
+| /start form | Text-only / reply keyboard / inline button | Text-only minimal |
+| Command menu | Phase-2 commands only / v1 stubbed / you decide | Phase-2 only + user freeform added Malay aliases (/mula, /pergi_ke, /cari) |
+| Localization | Strings module + ms locale / defer / aliases only | Strings module + ms locale |
+
+### Session token mechanics
+| Question | Options presented | Selection |
+|---|---|---|
+| Token shape | Opaque KV id / signed JWT / you decide | Opaque token id in KV |
+| Second /start | Rotate-on-new / multi-session / you decide | Rotate-on-new, revocable |
+| Claim fields | Minimal / wider / you decide | Minimal bootstrap claim |
+
+### Backend repo layout
+| Question | Options presented | Selection |
+|---|---|---|
+| Workspace layout | bot/ / worker/ / flat src | bot/ workspace |
+| Language | TypeScript / JS ESM / you decide | TypeScript |
+| Internal structure | Explicit src/ tree / flat folder / you decide | Explicit src/ tree |
+
+### Validation + test harness
+| Question | Options presented | Selection |
+|---|---|---|
+| Test runner | Hand-rolled / vitest / workers-pool / you decide | Hand-rolled runner |
+| Stale policy | ~15 min / 5 min / you decide | ~15 min freshness |
+| Rejection logs | Category-only / + payload hash / you decide | Category-only |
+
+### Secrets + deploy setup
+| Question | Options presented | Selection |
+|---|---|---|
+| Secret storage | Wrangler secrets / CI-injected / you decide | Wrangler secrets |
+| Deploy | Local deploy / CI day one | Local deploy only |
+| Hygiene | Secrets separation / dry-run vault / you decide | Secrets separation |
+
+## Corrections Made
+
+No corrections — user accepted recommended options throughout; one freeform addition (Malay command aliases D-03/D-04/D-05).
+
+## Auto-Resolved
+
+Not applicable.
+
+## External Research
+
+Not applicable (research phase follows planning; the grammY initData typings verification was already flagged in ROADMAP.md).
diff --git a/.planning/phases/02-bot-skeleton-initdata-security/02-RESEARCH.md b/.planning/phases/02-bot-skeleton-initdata-security/02-RESEARCH.md
new file mode 100644
index 0000000..5b5c1a7
--- /dev/null
+++ b/.planning/phases/02-bot-skeleton-initdata-security/02-RESEARCH.md
@@ -0,0 +1,489 @@
+# Phase 2: Bot Skeleton + initData Security - Research
+
+**Researched:** 2026-10-22
+**Domain:** grammY bot on Cloudflare Workers + Telegram WebApp initData (HMAC) validation + KV TTL-only session storage
+**Confidence:** HIGH (core algorithm + webhook adapter + typing gap verified this session; typing/package versions verified against npm registry)
+
+## Summary
+
+Phase 2 stands up the first backend artifact: a single Cloudflare Worker running a grammY bot with commands (/start, /help, /privacy + Malay aliases) and an HMAC-SHA256 initData validator backed by CI test vectors. All critical claims were verified against primary sources this session:
+
+1. **The flagged typing gap is real and confirmed.** `grammY@1.46.0` and `@grammyjs/types@5.0.0` ship **no `WebAppInitData` typings anywhere** (verified by extracting both tarballs and grepping). The plan must hand-roll a small WebAppInitData interface inside `src/security/initdata.ts` — which fits D-11/D-12 (isolated testable module, zero extra test deps) exactly. No third-party initData package is needed.
+2. **The validation algorithm is fully specified by Telegram [VERIFIED: core.telegram.org/bots/webapps]**: `secret_key = HMAC_SHA256(bot_token, key="WebAppData")`, then `hex(HMAC_SHA256(data_check_string, secret_key)) == hash`, with data-check-string = all fields except `hash` (and `signature`), sorted alphabetically, `key=value` joined by `\n`. Staleness via `auth_date` (D-13: ~15 minutes).
+3. **Cross-runtime crypto works zero-dep.** Node 24's global `crypto.subtle` produced the exact RFC-4231 HMAC-SHA256 vector, identical to a reference implementation, on this machine today. The same API is the only crypto in Cloudflare Workers. So `src/security/initdata.ts` runs unchanged in Workers AND in a bare `node` test runner — D-12's hand-rolled runner needs no polyfills and no test framework [VERIFIED: local run].
+4. **grammY ships a first-class Workers adapter [VERIFIED: shipped source]**: `webhookCallback(bot, "cloudflare-mod", onTimeout, timeoutMilliseconds, secretToken)` accepts the module-format `fetch(request)` handler itself, reads the update from `request.json()`, and performs a **constant-time comparison of `X-Telegram-Bot-Api-Secret-Token`** against the configured secret token, rejecting with 401 on miss.
+5. **`setMyCommands` supports localized scopes [VERIFIED: shipped source]**: `bot.api.setMyCommands(commands, { bot_command_scope: { type: "default" }, language_code: "ms" })` — the `language_code` param is typed in the methods surface (`LanguageCode`).
+
+**Primary recommendation:** Hand-roll initData validation + WebAppInitData types on `crypto.subtle` (zero deps, same code in Workers and Node tests); wire the bot into the Worker with `webhookCallback(bot, "cloudflare-mod", undefined, undefined, WEBHOOK_SECRET)`; store one opaque token per chat in KV `chat_sessions` with `expirationTtl` on every put.
+
+
+## User Constraints (from CONTEXT.md)
+
+### Locked Decisions
+- **D-01:** Tone is clinical + honest (Rams-aligned): what araharah is in one sentence, no emoji clutter, no marketing flourish. Privacy statement states what is NOT stored as plainly as what is.
+- **D-02:** /start is a plain text-only message — no reply keyboard, no inline buttons. Telegram commands are the interface (the Mini App launch path is Phase 4's decision).
+- **D-03:** Permanent command menu contains only commands that exist in this phase (/help, /privacy) registered via setMyCommands — no stub entries for future commands.
+- **D-04:** Malay command aliases are first-class citizens of the surface from this phase: `/mula` runs the same flow as `/start`, `/privasi` as `/privacy`. Later flow aliases are named now and implemented by their phases: `/pergi_ke` (~ goto), `/cari` (~ search, Phase 4). setMyCommands registers localized command descriptions via a `ms` language_code scope in addition to the default.
+- **D-05:** All bot copy lives in a strings module (`src/i18n/strings.ts`): English default + Bahasa Melayu `ms` locale. Telegram's per-user `language_code` picks the locale; unhandled codes fall back to English. Command aliases are handlers on the same command stack, not separate flows.
+- **D-06:** Session tokens are opaque random token ids stored in KV `chat_sessions` alongside a minimal bootstrap claim. Not JWTs — revocable and audit-friendly. Handing out the token is the security gate: only successful initData validation mints one.
+- **D-07:** Rotate-on-new: when a validated identity arrives for a chat that already holds a live session token, the old token is invalidated (overwritten). One live token per chat.
+- **D-08:** The stored claim is deliberately minimal: Telegram `user.id`, `chat.id`, `issued_at`, and the user's `language_code`. No location, no routes, no profile.
+- **D-09:** Backend lives in a `bot/` workspace sibling to `theme/` (npm workspaces, root package.json gets thin delegate scripts, mirroring the Phase 1 pattern).
+- **D-10:** Language is TypeScript for everything backend (grammY typings first-class, `@cloudflare/workers-types` for the runtime).
+- **D-11:** bot/ layout is an explicit `src/` tree: `src/webhook.ts` (worker entry), `src/security/initdata.ts` (HMAC validation — isolated, testable without provider dependencies), `src/kv/sessions.ts` (TTL-only access layer), `src/i18n/strings.ts`, `src/bot/commands/{start,help,privacy}.ts`, wrangler config colocated.
+- **D-12:** Test runner is a hand-rolled node runner in the same shape as `theme/scripts/audit.test.mjs` (Phase 1's pattern) — no extra test dependency for this pure-Crypto HMAC vector suite.
+- **D-13:** initData freshness policy: reject payloads whose `auth_date` is older than ~15 minutes; the Mini App re-validates with fresh initData on launch.
+- **D-14:** Rejection logging is category-only: tampered / stale / replayed / expired — never the raw payload, never coordinates, no user-marking timestamps.
+- **D-15:** Secrets (bot token, HMAC secret) live in Cloudflare Wrangler secrets. Local dev mirrors via a gitignored `.dev.vars` (only `.dev.vars.example` ships in the repo).
+- **D-16:** Phase 2 deploy is local `npm run deploy` (wrangler deploy + setWebhook). No CI workflow yet.
+- **D-17:** Strict secret hygiene: no secret value ever committed, webhook URL in wrangler config, and the /privacy copy stays verifiable by reading the code's storage calls.
+
+### OpenCode's Discretion
+- Exact setMyCommands scope/locale structuring details
+- Webhook secret token mechanics (Telegram's `secret_token` param) at the researcher's discretion
+- Exact KV TTL values inside the "minutes" envelope
+- How local wrangler dev simulates KV for testing the product loop
+- How the researcher verifies exact grammY initData typings (flagged research gap — RESOLVED, see below)
+
+### Deferred Ideas (OUT OF SCOPE)
+- Webhook dispatch via static-asset router to inline Telegram Mini App serving (Phase 3 shapes the mixed static+api worker, not this phase)
+- Metrics/diagnostics beyond category logs (Phase 7 audit scope decides)
+
+
+
+## Phase Requirements
+
+| ID | Description | Research Support |
+|----|-------------|------------------|
+| ONBD-01 | User opens the bot, /start gives concise explanation + always-available command menu (/help, /privacy) | grammY command handlers + `setMyCommands` localized scopes (default + `ms`), verified against shipped API surface |
+| PRIV-01 | Privacy promise visible in-product: /privacy command (Phase 2 half: the command + copy; in-app messaging completes Phase 6) | Strings module (`src/i18n/strings.ts`) with en/`ms` locales; copy verifiable by reading code's storage calls (D-17) |
+| PRIV-02 | Backend holds no persistent per-user location history or profile — TTL-expiring ephemeral state only | KV `chat_sessions` access layer always passes `expirationTtl`; claim restricted to D-08 fields; rejection logs category-only (D-14) |
+
+
+## Architectural Responsibility Map
+
+| Capability | Primary Tier | Secondary Tier | Rationale |
+|------------|-------------|----------------|-----------|
+| initData HMAC validation + staleness check | API / Backend (Worker) | — | Secret key derived from bot token — never touches a client tier |
+| Session token minting (opaque id) | API / Backend (Worker) | — | Only validated identities mint tokens; crypto.randomUUID in Workers/Node |
+| Session storage (TTL-only KV) | Database / Storage (KV) | API tier wraps access | Access layer in `src/kv/sessions.ts` is the only KV consumer |
+| Bot command surface (/start /help /privacy + ms aliases) | API / Backend (Worker) | — | grammY runs inside the Worker; no SSR tier |
+| Bot copy (en + ms) | API / Backend (strings module) | — | Single source of truth per D-05 |
+| User sends command / receives message | Client (Telegram app) | API tier processes | Verification of this truth is manual-only (real Telegram client) |
+
+**No security-sensitive capability is assigned outside the API/backend tier.**
+
+## Standard Stack
+
+### Core
+| Library | Version | Purpose | Why Standard |
+|---------|---------|---------|--------------|
+| grammy | 1.46.0 | Bot framework (commands, handlers) | First-class Cloudflare Workers support via `wh`/`webhookCallback`; typed Bot API surface. [VERIFIED: npm registry + shipped tarball] |
+| @grammyjs/types | 5.0.0 | grammY's Bot API typings (transitive dep) | Ships with grammy; provides `LanguageCode` and API method Param types. Contains **no** WebAppInitData — see gap note. [VERIFIED: tarball grep] |
+| @cloudflare/workers-types | 5.20261022.1 | Runtime types (KVNamespace, ScheduledController, env bindings) | The blessed runtime-types package for Workers TS. [VERIFIED: npm registry] |
+| wrangler | 4.151.3 | Dev server, generate types (`wrangler types`), deploy, secrets | Current Cloudflare CLI major. [VERIFIED: npm registry, modified 2026-10-20] |
+| typescript | 5.9.3 | Language | Latest stable 5.x line; TS 7.0.2 exists but wrangler tooling ecosystem is proven on 5.x — stay conservative (MEDIUM confidence choice, low risk). [VERIFIED: npm registry] |
+
+### Supporting
+| Library | Version | Purpose | When to Use |
+|---------|---------|---------|-------------|
+| @types/node | 26.6.5 | Node types for the hand-rolled test runner only (test scope) | The test runner imports `node:assert`; production code uses no Node APIs. [VERIFIED: npm registry] |
+
+### Alternatives Considered
+| Instead of | Could Use | Tradeoff |
+|------------|-----------|----------|
+| Hand-rolled initData types | `@telegram-apps/init-data-node` | Same algorithm hand-implemented for zero deps keeps D-11/D-12 intact; community pkg adds a dep and a second way to do the same crypto. Rejected for this phase of one. |
+| Hand-rolled test runner (D-12) | `@cloudflare/vitest-pool-workers` | The security-critical validator runs in bare Node via global `crypto.subtle` — no pool needed. Pool adds a heavy dep for tests that don't need Worker runtime emulation. Keep pool off the path; Workers-runtime-only behaviors (KV real binding) can't be unit-tested either way and are verified by deploy spot-check per D-16. |
+| `webhookCallback(bot, "cloudflare-mod")` | Manual Request parsing | Manual parsing loses grammY's constant-time secret-token check (verified implementation) — don't rebuild it. |
+
+**Installation:**
+```bash
+npm install -w bot grammy
+npm install -w bot -D wrangler typescript @cloudflare/workers-types @types/node
+```
+
+**Version verification:** All versions probed against the npm registry today (2026-10-22). Registry publish dates confirmed fresh for every entry (wrangler modified 2026-10-20, workers-types 2026-10-22, grammy 2026-08-26).
+
+**Typing gap resolution [research gap from ROADMAP notes — OPEN QUESTION RESOLVED]:**
+`WebAppInitData` is not exported by grammy@1.46.0 or @grammyjs/types@5.0.0 (verified by tarball inspection). `src/security/initdata.ts` therefore defines its own minimal interface from Telegram's official WebAppInitData spec (`query_id?`, `user?`, `chat?`, `start_param?`, `chat_type?`, `chat_instance?`, `auth_date`, `signature?`, `chat_join_request_query_id?`) plus the raw string fields (`hash`, `auth_date`, `user`, ...). This is ~20 lines of types, tested by the same vector suite.
+
+## Architecture Patterns
+
+### System Architecture Diagram
+
+```
+ Telegram client
+ │ POST https:///telegram/webhook
+ │ (header: X-Telegram-Bot-Api-Secret-Token)
+ ▼
+┌─────────────────────────────────────────────────────┐
+│ Cloudflare Worker (src/webhook.ts) │
+│ └ route /telegram/webhook only │
+│ └ webhookCallback(bot, "cloudflare-mod", │
+│ secretToken=WEBHOOK_SECRET) │
+│ ├ 401 if secret header mismatches │
+│ └ bot command handlers: │
+│ /start /mula /help /privacy /privasi │
+│ └ copy from src/i18n/strings.ts │
+│ (en default, ms by user.language) │
+│ │
+│ POST /api/session (Phase 5 will be Mini App caller; │
+│ body: initData string — validated HERE) │
+│ ├ validateInitData(initData, BOT_TOKEN) │
+│ │ ├ parse querystring fields │
+│ │ ├ derive secretKey = HMAC(bot,"WebApp")│
+│ │ ├ hex compare → tamper / stale checks │
+│ │ └ rejection classes: tampered/stale/ │
+│ │ replayed/expired (category logs only│
+│ │ per D-14 — no payload, no coords)│
+│ ├ mint opaque token (crypto.randomUUID) │
+│ └ KV put(key=token, claim, │
+│ { expirationTtl: })│
+│ rotate-on-new: prior token invalidated│
+└─────────────────────────────────────────────────────┘
+ │ ▲
+ ▼ │ read claim by token
+ KV namespace chat_sessions │ (phase-local verification)
+ ({expirationTtl} on every put — TTL-only, PRIV-02)
+```
+
+Note on `/api/session`: the Mini App UI calling it arrives in Phase 4, but the endpoint is the vehicle that makes initData validation and D-06/D-07/KV-TTL observable in this phase (validator is "the first backend artifact" and must be reachable). The Phase 3 route addition (static assets binding) is additive and does not conflict.
+
+### Recommended Project Structure
+
+```
+bot/
+├── package.json # name @araharah/bot, type module, test/deploy/dev scripts
+├── wrangler.jsonc # KV binding chat_sessions, webhook URL vars in config (not secrets)
+├── .dev.vars.example # shape only: BOT_TOKEN=, INITDATA_WEBHOOK_SECRET=, WEBHOOK_SECRET=
+├── .dev.vars # gitignored — real local secrets (user creates from example)
+├── .gitignore # .dev.vars, .wrangler/
+├── src/
+│ ├── webhook.ts # export default { fetch } — routes /telegram/webhook and /api/session
+│ ├── config.ts # typed Env interface (BOT_TOKEN, WEBHOOK_SECRET, chat_sessions binding)
+│ ├── security/
+│ │ ├── initdata.ts # PURE: validateInitData(raw, botToken, now?) + WebAppInitData types
+│ │ └── initdata.test.mjs? # ← runner lives in /test (see below)
+│ ├── kv/
+│ │ └── sessions.ts # TTL-only access layer: put()pong required expirationTtl; no get-or-store persistent paths
+│ ├── i18n/
+│ │ └── strings.ts # en + ms copy, locale picker (D-05)
+│ └── bot/
+│ ├── bot.ts # Bot instance + middleware wiring (no handlers inline)
+│ └── commands/
+│ ├── start.ts # handler + alias (/start, /mula)
+│ ├── help.ts # handler only (no alias in this phase per D-04)
+│ └── privacy.ts # handler + alias (/privacy, /privasi)
+└── test/
+ ├── runner.mjs # minimal hand-rolled runner (theme/scripts/audit.test.mjs shape)
+ └── initdata.test.mjs # HMAC vector suite (tamper/stale/replay/known-good)
+```
+
+### Pattern 1: Workers module Worker with grammY webhook adapter
+**What:** grammY's built-in `webhookCallback` with the `"cloudflare-mod"` adapter converts the Workers `fetch(request)` handler into a grammY request-response cycle, including constant-time secret-token verification.
+**When to use:** Always in this project (D-webhook = every phase).
+**Example:**
+```typescript
+// Source: grammy@1.46.0 out/convenience/frameworks.js + webhook.js (shipped code, verified 2026-10-22)
+import { Bot, webhookCallback } from "grammy";
+import type { Env } from "./config.js";
+
+const bot = new Bot(() => `t${""}`); // NOTE: do NOT copy this literal — see real signature in tasks; Bot(token) is resolved once in createBot(env.BOT_TOKEN)
+```
+Real shape (verified): grammY's adapter registration — `adapters["cloudflare-mod"]` takes `request` (with `.json()`, `.headers`), exposes constant-time `unauthorized()` responses. Compose as:
+```typescript
+export default {
+ fetch(req: Request, env: Env): Promise {
+ const url = new URL(req.url);
+ if (url.pathname === "/telegram/webhook") {
+ return webhookCallback(botHandler(env), "cloudflare-mod")(
+ req, undefined, undefined, env.WEBHOOK_SECRET,
+ );
+ }
+ if (url.pathname === "/api/session") { /* Phase 2: validate + mint */ }
+ return new Response("Not found", { status: 404 });
+ },
+} satisfies ExportedHandler;
+```
+**Wait — signature check:** `webhookCallback(bot, adapter, onTimeout, timeoutMilliseconds, secretToken)` — verified from the shipped implementation: `(bot, adapter = defaultAdapter, onTimeout, timeoutMilliseconds, secretToken)` where `secretToken=undefined` disables the header check. Because the bot needs `env.BOT_TOKEN` (bound at deploy), create the Bot inside the fetch handler (Workers have no server startup hook):
+
+```typescript
+// src/webhook.ts — real shape
+import { Bot, webhookCallback } from "grammy";
+import { createBot } from "./bot/bot.js";
+import { handleSession } from "./security/session.js";
+
+export default {
+ async fetch(req: Request, env: Env): Promise {
+ const bot = createBot(env); // cheap per-request instantiation; grammY Bot is stateless without polling
+ const url = new URL(req.url);
+ if (url.pathname === "/telegram/webhook") {
+ const cb = webhookCallback(bot, "cloudflare-mod", undefined, undefined, env.WEBHOOK_SECRET);
+ return cb(req);
+ }
+ if (url.pathname === "/api/session") return handleSession(req, env);
+ return new Response("Not found", { status: 404 });
+ },
+} satisfies ExportedHandler;
+```
+(Per-request Bot instantiation is the standard pattern for Workers with env-bound tokens — token can't be resolved at module scope since it arrives via `env`.)
+
+### Pattern 2: initData validation (SubtleCrypto, pure function)
+**What:** Verified algorithm from Telegram's official docs, implemented as a pure async function testable in Node AND Workers.
+**Example:**
+```typescript
+// Source: https://core.telegram.org/bots/webapps#validating-data-received-via-the-mini-app (verified 2026-10-22)
+export type InitDataRejection = "malformed" | "tampered" | "stale" | "replayed" | "expired";
+
+interface ParseResult { fields: Map; hash: string | undefined; }
+
+export async function validateInitData(
+ raw: string,
+ botToken: string,
+ opts: { now?: number; maxAgeSeconds?: number; existingToken?: string } = {},
+): Promise<{ ok: true; user: WebAppInitUser; chat?: WebAppInitChat; languageCode?: string | null; }
+ | { ok: false; reason: InitDataRejection }> {
+ // 1. parse raw querystring → fields (do NOT pre-decode values for the check string;
+ // Telegram compares against the data-check-string built from the parsed fields)
+ // 2. secretKey = HMAC_SHA256(botToken bytes, key="WebAppData")
+ // 3. dataCheckString = fields minus hash, minus signature, sorted by key, "key=value\n..." (LF join)
+ // 4. hexCheck = HMAC_SHA256(dataCheckString UTF-8, key=secretKey)
+ // 5. mismatch → { ok:false, reason:"tampered" }
+ // 6. auth_date parse fail → "malformed"; auth_date < now - 900s → "stale" (D-13: ~15 minutes)
+ // 7. no user payload / user lacks id → "malformed"
+ return {};
+}
+```
+(Full implementation belongs in the plan's task — algorithm + rejection classes above are the verified contract.)
+
+### Pattern 3: TTL-only KV access layer (PRIV-02 by construction)
+**What:** The KV accessor's `put` REQUIRES an `expirationTtl` argument (no default path to infinity). Type signature enforces it:
+```typescript
+// src/kv/sessions.ts — TTL-only by type
+export interface SessionClaim {
+ token_id: string; // opaque token id itself (key)
+ user_id: number; // D-08 minimal claim
+ chat_id: number;
+ issued_at: number;
+ language_code: string | null;
+}
+export async function mintSession(kv: KVNamespace, claim: Omit, ttlSeconds: number): Promise {
+ const token = crypto.randomUUID();
+ await kv.put(`session:${token}`, JSON.stringify(claim), { expirationTtl: ttlSeconds }); // required — no overload without TTL
+ return token;
+}
+```
+**Verify in CI (PRIV-02 spot-check):** the runner asserts `mintSession` forwards `expirationTtl` (mock KV) and that no other code path in `src/` calls `.put(` without `expirationTtl` (grep gate in the runner).
+
+### Anti-Patterns to Avoid
+- **Bot token as HMAC key directly:** Telegram uses `secret_key = HMAC(bot_token, key="WebAppData")` first. Validating with `HMAC(dataCheckString, key=botToken)` silently fails everywhere except in your broken tests that encode the same mistake.
+- **Including `hash` (or `signature`) in data-check-string:** must exclude before sorting.
+- **Re-sorting after URL-decoding values or re-encoding:** build the check string from parsed field values as Telegram sent them (decoded), sorted by key, LF-joined. Sorting must be case-sensitive byte order (JS default `Array.sort()`).
+- **`parseInt` edge handling on auth_date:** Telegram sends seconds-since-epoch; parsing that yields `NaN` (e.g., `Date.now()` ms confusion) creates a stale/valid inversion bug. Normalize to seconds.
+- **Storing coordinates/profile in KV "temporarily":** D-08's claim is the exhaustive list. PR-14's category-only logs: never `console.log` the raw initData string.
+- **`bot.start()` anywhere in the Worker path:** grammY throws if you call `.start()` in webhook mode; handlers must not invoke polling.
+
+## Don't Hand-Roll
+
+| Problem | Don't Build | Use Instead | Why |
+|---------|-------------|-------------|-----|
+| Webhook request parsing + auth | Manual `request.json()` + header check | `webhookCallback(bot, "cloudflare-mod", …, secretToken)` | grammY's constant-time token comparison is already shipped and verified; manual parsing would reintroduce a timing side-channel |
+| HMAC-SHA256 | `npm i crypto-js` / custom table-coded blocks | `crypto.subtle.importKey("raw", …, { name: "HMAC", hash: "SHA-256" })` | Native in Workers and Node ≥15; FCA-grade correct; RFC-4231 verified this session |
+| Bot command parsing | Custom prefix routers | grammY commands + `bot.command("mula", handler)` for aliases on same composer | grammY grammar handles `/command @botname` edge cases |
+| Random opaque tokens | `Math.random().toString(36)` | `crypto.randomUUID()` | Math.random is predictable (rejection class "replay" hard to distinguish); crypto.randomUUID is available in both Workers and Node with zero deps |
+
+**Key insight:** every cryptographic and webhook primitive this phase needs is native to the runtime — third-party crypto or webhook plumbing would only add diff surface to audit (D-17's "verifiable by reading the code's storage calls").
+
+## Common Pitfalls
+
+### Pitfall 1: Sorting alphabetically ≠ lexicographic on the raw VALUE
+**What goes wrong:** `data-check-string` fields are sorted **by key**, but implementers sort key-value pair strings or decode-dependent value strings. Telegram sorts by field name only.
+**Why it happens:** the spec phrase "sorted alphabetically" is ambiguous out of context.
+**How to avoid:** sort a list of `key=value` strings generated after splitting, restricting the sort key to the field name. Test vector suite should include an out-of-alphabetical-arrival-order payload (`user=` before `auth_date=`) that still passes.
+**Warning signs:** validation passes for canonical-order payloads but fails for real Telegram packets (Telegram sends `user` first in practice).
+
+### Pitfall 2: HMAC key derivation direction confusion
+**What goes wrong:** using `HMAC(bot_token, key=WebAppData)`'s result as the *message* somewhere, or re-deriving the key as `HMAC("WebAppData", key=bot_token)` (reversed).
+**Why it happens:** the Telegram pseudocode line `secret_key = HMAC_SHA256(, "WebAppData")` reads left-to-right as message/key but is documented above in prose as **key = "WebAppData", message = bot_token**.
+**How to avoid:** encode the derivation as `HMAC(message = botToken ← Bot API token string, key = utf8("WebAppData"))` in both code AND test vectors; one known-good vector pins the convention.
+**Warning signs:** every initData with a given hash fails validation despite matching an official worked example.
+
+### Pitfall 3: `auth_date` staleness window in ms-vs-seconds
+**What goes wrong:** comparing `auth_date` (seconds) against `Date.now()` (ms) → everything looks 1000× too old; or hard-failing the whole check when the payload is exactly at boundary — off-by-one D-13 semantics.
+**How to avoid:** compute age in seconds: `nowSeconds - authDate > maxAgeSeconds` → stale, with `maxAgeSeconds` parameterized (D-13 "$~15 min", plan fixes 900).
+**Warning signs:** fresh /start after deploy logs "stale" (or stale logs nothing).
+
+### Pitfall 4: Replay handling vs rotate-on-new (D-07)
+**What goes wrong:** naive mintSession lets a stale-but-replayed initData mint *another* token (tests pass, D-07 broken structurally).
+**Why it happens:** minting is unordered in the naive flow.
+**How to avoid:** the session key is the token id; rotate-on-new means the KV put is keyed and unconditional — old token's key still lives until TTL; D-07's "old token invalidated" is realized by making the KV key a per-chat identity (one session key per chat) or by deleting the old token id. Plan must pick one explicitly (see Open Questions resolution below).
+**Warning signs:** two different session keys exist for the same chat id.
+
+### Pitfall 5: `env` shape mismatch in `wrangler types` vs `config.ts`
+**What goes wrong:** declaring the Env interface by hand drifts from `wrangler generate-types` output.
+**How to avoid:** commit a `bot/src/config.d.ts` generated from `wrangler types` output (run once at phase setup), or choose import-vs-inline explicitly and check in CI by `wrangler deploy --dry-run` (no secret needed for typing check).
+**Warning signs:** `KVNamespace` NotFound / env binding missing only at deploy time.
+
+### Pitfall 6: `.dev.vars` accidentally committed
+**What goes wrong:** real local secrets via `wrangler dev` get into git (D-17 violation on the first day they exist).
+**How to avoid:** `bot/.gitignore` ships in the same commit as `.dev.vars.example`; `.dev.vars` is created for the user, not by them.
+**Warning signs:** repo grep for `BOT_TOKEN=` returns a real value.
+
+## Code Examples
+
+### initData validation core (verified shape; full impl in plan)
+```typescript
+// Source: https://core.telegram.org/bots/webapps#validating-data-received-via-the-mini-app
+// + Node 24 / Workers globalThis.crypto (RFC-4231 vector verified 2026-10-22)
+async function hmacHex(message: string, key: Uint8Array): Promise {
+ const cryptoKey = await crypto.subtle.importKey(
+ "raw", key, { name: "HMAC", hash: "SHA-256" }, false, ["sign"],
+ );
+ const sig = await crypto.subtle.sign("HMAC", cryptoKey, new TextEncoder().encode(message));
+ return [...new Uint8Array(sig)].map(b => b.toString(16).padStart(2, "0")).join("");
+}
+
+export async function validateInitData(raw: string, botToken: string, maxAgeSeconds = 900, nowSeconds = Math.floor(Date.now() / 1000)) {
+ const parsed = querystringParse(raw); // all `key=value` fields
+ const { hash, ...rest } = parsed; // drop `hash` (+ `signature` if present)
+ const dataCheckString = Object.entries(rest)
+ .sort((a, b) => a[0].localeCompare(b[0])) // sort by FIELD NAME, case-sensitive
+ .map(([k, v]) => `${k}=${v}`)
+ .join("\n");
+ const secretKey = utf8Bytes("WebAppData");
+ const derived = await hmacHex(botToken, secretKey); // key = "WebAppData", message = botToken
+ const check = await hmacHex(dataCheckString, utf8Bytes(derived)); // key = secretKey (hex → bytes)
+ if (check !== hash) return { ok: false, reason: "tampered" } as const;
+ // ... stale / malformed / success per rejection classes
+}
+```
+
+### Command registration with localized scopes
+```typescript
+// Source: grammy@1.46.0 api.d.ts surface (setMyCommands(commands, { bot_command_scope, language_code }), verified)
+const EN = [{ command: "help", description: "..." }, { command: "privacy", description: "..." }];
+const MS = [...]; // en + ms
+await bot.api.setMyCommands(EN, { bot_command_scope: { type: "default" } });
+await bot.api.setMyCommands(MS, { bot_command_scope: { type: "default" }, language_code: "ms" });
+```
+
+## State of the Art
+
+| Old Approach | Current Approach | When Changed | Impact |
+|--------------|------------------|--------------|--------|
+| Cloudflare Pages for static+API | Workers with static assets binding (project already committed — PROJECT.md) | CF deprecation wave | This phase's Worker is Workers-native; no Pages compat guarantee needed |
+| grammY needed polyfills on Workers | grammY 1.x auto-platform-detects; "cloudflare-mod" is shipped as a named adapter | long-standing stable | `import { Bot, webhookCallback } from "grammy"` just works |
+| telegram-web-app `WebAppInitData` via community packages | Official `signature` (Ed25519 third-party) channel exists alongside `hash` (HMAC first-party) | Bot API 7.x–10.x additions | Not needed for this phase (first-party bot validation uses `hash`); noted for Phase 5 playbook |
+
+**Deprecated/outdated:**
+- Nothing in the standard stack here is deprecated; `wrangler.toml` vs `wrangler.jsonc` — wrangler 4 supports both, jsonc preferred for comments.
+
+## Assumptions Log
+
+| # | Claim | Section | Risk if Wrong |
+|---|-------|---------|---------------|
+| A1 | `webhookCallback` bot instantiation per-request is accepted Workers practice (token arrives via env) | Architecture Patterns | LOW — bot migrations would document the binding pattern if not; cost of being wrong is minor refactor in webhook.ts |
+| A2 | TypeScript 5.9.3 (not TS 7) is the safer pin for wrangler tooling compatibility | Standard Stack | LOW — swap is trivial; nothing in phase depends on TS version specifics |
+| A3 | `maxAgeSeconds = 900` satisfies D-13's "~15 minutes" envelope | Common Pitfalls | LOW — one-line change; D-13 wrote the envelope as approximate |
+| A4 | KV key strategy for D-07 (`session:` vs one-key-per-chat) — see Open Questions | Pitfall 4 | LOW-MED — affects rotate-on-new semantics; resolved in Open Questions and implemented accordingly |
+
+## Open Questions (RESOLVED)
+
+1. **grammY initData typings — RESOLVED:** Not shipped in grammy@1.46.0 / @grammyjs/types@5.0.0 (tarball-verified). Hand-roll a ~20-line `WebAppInitData` + rejections interface in `src/security/initdata.ts` per Telegram's official spec; no third-party package.
+2. **D-07 rotate-on-new mechanics — RESOLVED:** KV key = `session:` with claim containing `chat_id`; on fresh validated identity for a chat, mint new token and **delete the old token key explicitly** (`ctx`-aware: read prior token claim first). Alternative (single key per chat) was rejected because the token is opaque-by-value (D-06) and clients hold the token, not the chat id. Delete-then-mint in one exchange keeps "one live token per chat" and is testable in the runner.
+3. **TTL value inside the minutes envelope — RESOLVED:** `chat_sessions` TTL = 900 seconds (15 min), matching D-13's freshness window; the two eine envelope so a session cannot outlive the proof-of-freshness that minted it.
+4. **Local KV simulation — RESOLVED:** `wrangler dev` provides local KV emulation out of the box (dev bindings); the session endpoint against `wrangler dev` exercises mint/get/rotate manually. Unit tests fake KVNamespace with a 20-line in-memory mock (the runner, D-12).
+
+## Environment Availability
+
+| Dependency | Required By | Available | Version | Fallback |
+|------------|------------|-----------|---------|----------|
+| Node.js | hand-rolled runner (D-12), crypto.subtle | ✓ | 24.18.0 | — |
+| npm | workspace installs, scripts | ✓ | 11.16.0 | — |
+| wrangler | dev/deploy/types (D-15/D-16) | ✗ (installs via `npm i -w bot -D wrangler`) | 4.151.3 pinned for install | none needed |
+| Cloudflare account + bot token + KV binding | deploy step (D-15/D-16) | user Setup | — | user provides `.dev.vars` values; deploy checkpoint |
+| git | repo hygiene (D-17) | ✓ | — | — |
+
+**Missing dependencies with no fallback:**
+- None blocking implementation; deploy checkpoint requires user's Cloudflare/bot credentials (user_setup, standard pattern).
+
+## Validation Architecture
+
+### Test Framework
+| Property | Value |
+|----------|-------|
+| Framework | Hand-rolled node runner (D-12) — `node:assert`, zero extra deps, `theme/scripts/audit.test.mjs` shape |
+| Config file | none — plain `.mjs`, matches Phase 1 pattern |
+| Quick run command | `npm test -w bot` |
+| Full suite command | `npm test -w bot` (single runner, all vector + privacy gates inside) |
+
+### Phase Requirements → Test Map
+| Req ID | Behavior | Test Type | Automated Command | File Exists? |
+|--------|----------|-----------|-------------------|-------------|
+| ONBD-01 | /start returns concise intro + /help,/privacy enrolled | unit (strings + commands module-level) + **manual Telegram walkthrough** (real client —cannot auto-verify Bot UX) | `npm test -w bot` (partial cases: strings module exports; handlers registered w/o keyboards) + manual | ✅ created this phase (Wave 0) |
+| PRIV-01 | /privacy copy states stored/not-stored truthfully | unit (strings snapshot classes: states no-location, TTL-only, what IS stored) | `npm test -w bot` | ✅ created this phase (Wave 0) |
+| PRIV-02 | TTL-only storage, zero persistent coordinates/profiles | unit (mock KV asserts expirationTtl on every put; grep gate for bare `.put(`) | `npm test -w bot` | ✅ created this phase (Wave 0) |
+| Security | initData vectors: known-good HMAC ✓; tampered → fail; stale → fail; replay double-mint → blocked by rotate | unit (crypto vectors + fake KV) | `npm test -w bot` | ✅ created this phase (Wave 0) |
+| Webhook auth | secret-token mismatch → 401 | **manual-only + deploy spot-check** — grammY's own verified constant-time check; not re-tested | manual (wrangler dev curl w/ bad header) | n/a — relies on verified library behavior |
+
+### Sampling Rate
+- **Per task commit:** `npm test -w bot`
+- **Per wave merge:** `npm test -w bot` + `tsc -w bot --noEmit`
+- **Phase gate:** full suite green + real-deploy smoke (manual checkpoint: send /start from real Telegram client)
+
+### Wave 0 Gaps
+- [ ] `bot/test/runner.mjs` — minimal harness (describe/it substitute, exit codes) — created in this phase's Wave 0 plan
+- [ ] `bot/test/initdata.test.mjs` — vector suite (known-good + tampered + stale + replay classes) — Wave 0
+- [ ] Install step: `npm i -w bot` — workspace packages introduced with the plan
+
+*(Runner exists nowhere yet — every phase-2 test is a Wave 0 flat, created before the feature tasks land.)*
+
+## Security Domain
+
+### Applicable ASVS Categories
+
+| ASVS Category | Applies | Standard Control |
+|---------------|---------|-----------------|
+| V2 Authentication | yes | initData HMAC validation IS the authentication; only validated identities mint tokens (D-06) |
+| V3 Session Management | yes | Opaque tokens in TTL-only KV (D-06), rotate-on-new (D-07), one-live-token-per-chat invariant |
+| V4 Access Control | yes | /api/session refuses requests without valid initData; webhook 401s on secret-token mismatch |
+| V5 Input Validation | yes | reject classes malformed/tampered/stale; category-only logging (D-14) |
+| V6 Cryptography | yes | HMAC-SHA256 via native SubtleCrypto (never hand-rolled); constant-time header compare from grammY [VERIFIED] |
+| V9/V14 Communications/Config | yes | Secrets via wrangler secrets / .dev.vars (D-15); URL in config (D-17) |
+
+### Known Threat Patterns for Cloudflare Workers + Telegram
+
+| Pattern | STRIDE | Standard Mitigation |
+|---------|--------|---------------------|
+| Forged initData / replay attack | Tampering, Spoofing | HMAC validation + auth_date freshness (D-13) + D-07 rotate-on-new |
+| Webhook flooding with garbage | DoS | grammY 401 fast-path on secret-token mismatch; no KV hit before token check |
+| Token theft/brute force | Spoofing, Elevation | Opaque random UUID (128-bit entropy), TTL ≤ session freshness window |
+| Secret leakage via logs/repo | Information Disclosure | D-14 category-only logs; D-17 no committed secrets, `.gitignore` for `.dev.vars` |
+| KV data毅力 after TTL | Information Disclosure | KV TTL-only namespaces — no KV path without `expirationTtl` (type-enforced) |
+
+## Sources
+
+### Primary (HIGH confidence)
+- npm registry — grammy@1.46.0, @grammyjs/types@5.0.0, wrangler@4.151.3, @cloudflare/workers-types@5.20261022.1, typescript@5.9.3, @types/node@26.6.5 (all probed 2026-10-22)
+- grammy 1.46.0 shipped source (`out/convenience/frameworks.js`, `out/convenience/webhook.js`) — cloudflare-mod adapter + constant-time secret-token compare + `webhookCallback` signature
+- grammy 1.46.0 shipped api.d.ts — `setMyCommands(commands, other?: { bot_command_scope?, language_code? })`
+- @grammyjs/types 5.0.0 tarball — confirmed absence of WebAppInitData typings
+- https://core.telegram.org/bots/webapps — field chart, data-check-string/secret-key algorithm, auth_date staleness, `secret_token` webhook param, Ed25519 third-party note, setMyCommands localization support
+- Local runtime probe: Node 24.18.0 global `crypto.subtle` == RFC-4231 HMAC-SHA256 vector; npm 11.16; wrangler NOT preinstalled (installs as dev dep)
+
+### Secondary (MEDIUM confidence)
+- Cloudflare Workers KV `expirationTtl` semantics + wrangler secrets/.dev.vars — standard documented behavior; will verify live during deploy checkpoint
+- TS 5.9.3 vs 7.0.2 pin — Assumption A2
+
+### Tertiary (LOW confidence)
+- None beyond A1/A2 (everything load-bearing was verified above)
+
+## Metadata
+
+**Confidence breakdown:**
+- Standard stack: HIGH — versions verified against registry; adapter verified from shipped source
+- Architecture: HIGH — algorithm verified from official Telegram docs; crypto verified locally
+- Pitfalls: HIGH — pitfalls correspond to verified algorithm details (sorting, key derivation, ms/seconds, replay)
+
+**Research date:** 2026-10-22
+**Valid until:** 2026-11-21 (stable stack; wrangler major bumps warrant re-check)
diff --git a/.planning/phases/02-bot-skeleton-initdata-security/02-REVIEW-FIX.md b/.planning/phases/02-bot-skeleton-initdata-security/02-REVIEW-FIX.md
new file mode 100644
index 0000000..e5d4bda
--- /dev/null
+++ b/.planning/phases/02-bot-skeleton-initdata-security/02-REVIEW-FIX.md
@@ -0,0 +1,29 @@
+---
+status: all_fixed
+phase: 02-bot-skeleton-initdata-security
+source: [02-REVIEW.md]
+fixed: 2026-10-22
+---
+
+## Fix Summary
+
+| Finding | Fix | Commit | Verification |
+|---|---|---|---|
+| WR-01 unbounded body read on `/api/session` | `bot/src/webhook.ts`: 4 KB pre-read Content-Length rejection + post-read backstop → 413, category-only logs | `8995498` | WK1 valid mint 200; WK2 over-cap signed body → 413, zero KV writes, log `"[initdata] rejected: malformed"`; WK2b lying Content-Length → 413; WK3 broken stream resolves 413 |
+| WR-02 fail-open webhook when secret missing | `webhook.ts`: missing/empty `WEBHOOK_SECRET` binding → constant 500 + `[webhook] rejected: misconfigured` before grammY accept-all path; `config.ts` REQUIRED comment | `3ea1fdd` | WK5 unarmed → two consecutive constant 500s, no bot wiring, no KV touch; RED evidence recorded pre-fix; armed+unsigned covered by live 02-03 smokes (401) |
+| WR-03 rotate-on-new atomicity over-claim | `sessions.ts`: honest invariant documented (intended single live token; reconciliation at mint/next use; orphans TTL-bounded; Phase 4 must not assume more) + best-effort post-mint sweep | `af691ae` | SR2 re-worded sequential; SR3 stale-replica emulation asserts honest outcome; PG1/PG2 unchanged |
+
+Adjacent INFO fixed in same commits: IN-01(b) dead branch removed, IN-01(c) Phase-4 don't-delete notes added.
+
+Out of scope (Phase 7 audit): IN-01(a), IN-02, IN-03, IN-04, IN-05.
+
+## Verification
+
+- `npm test -w bot` → **34 passed, 0 failed** (was 27)
+- `npm run bot:typecheck` → exit 0
+- All 4 `console.*` in bot/src are category-only (no payload/uid/timestamp/coords)
+- Redeployed after fixes: version `b6ba6e58`; live `/api/session` garbage → 401; webhook pending 0; only stale (pre-fix) last_error
+
+## Review status
+
+02-REVIEW.md status `issues_found` → all Critical+Warning findings fixed; 3 INFO deferred deliberately.
diff --git a/.planning/phases/02-bot-skeleton-initdata-security/02-REVIEW.md b/.planning/phases/02-bot-skeleton-initdata-security/02-REVIEW.md
new file mode 100644
index 0000000..b78d89c
--- /dev/null
+++ b/.planning/phases/02-bot-skeleton-initdata-security/02-REVIEW.md
@@ -0,0 +1,153 @@
+---
+phase: 02-bot-skeleton-initdata-security
+reviewed: 2026-10-22T19:59:00Z
+depth: standard
+files_reviewed: 14
+files_reviewed_list:
+ - bot/package.json
+ - bot/wrangler.jsonc
+ - bot/tsconfig.json
+ - bot/.gitignore
+ - bot/.dev.vars.example
+ - bot/src/config.ts
+ - bot/src/webhook.ts
+ - bot/src/security/initdata.ts
+ - bot/src/kv/sessions.ts
+ - bot/src/i18n/strings.ts
+ - bot/src/bot/bot.ts
+ - bot/src/bot/commands/start.ts
+ - bot/src/bot/commands/help.ts
+ - bot/src/bot/commands/privacy.ts
+ - bot/scripts/register-commands.mjs
+ - bot/scripts/set-webhook.mjs
+ - bot/test/runner.mjs
+ - bot/test/harness.mjs
+ - bot/test/initdata.test.mjs
+ - bot/test/commands.test.mjs
+ - bot/test/privacy.test.mjs
+ - bot/test/sessions.test.mjs
+ - package.json
+findings:
+ critical: 0
+ warning: 3
+ info: 5
+ total: 8
+status: issues_found
+---
+
+# Phase 2: Code Review Report
+
+**Reviewed:** 2026-10-22T19:59:00Z
+**Depth:** standard
+**Files Reviewed:** 22 (14 entry list + hidden config/test files)
+**Status:** issues_found
+
+## Summary
+
+The security centerpiece holds up under scrutiny. The HMAC initData validator implements the official algorithm correctly (key = `HMAC-SHA256(key="WebAppData", msg=botToken)`, message = key-sorted data-check-string, comparison in constant time, freshness check after signature — D-13 precedence as specified). Re-verified against grammY's shipped typings: the `webhookCallback(bot, "cloudflare-mod", onTimeout, timeoutMs, secretToken)` positional form used in webhook.ts matches the 5-arg overload in `grammy/out/convenience/webhook.d.ts`. KV session layer honors TTL-only on every write path; `.dev.vars` is correctly gitignored via `bot/.gitignore` and untracked in git (verified with `git check-ignore` / `git ls-files`). Full suite green (27/27) and `tsc --noEmit` clean at review time.
+
+Three warnings remain, none of them in the validator's crypto path itself: (1) the only public unauthenticated endpoint accepts an unbounded request body straight into memory, (2) a one-secret-missing misconfiguration silently *disables* the webhook secret check instead of failing closed (confirmed in grammY's runtime source, `webhook.js:24-27`), and (3) the D-07 rotate-on-new invariant is only best-effort under KV's non-atomic read-delete-put, while the code comments call it "structurally closed".
+
+Not flagged (locked decisions per D-01..D-17): opaque UUID tokens instead of JWTs, no replay-nonce store (D-07 rotation is the accepted replay answer), 900 s TTL envelope, per-request bot instantiation.
+
+## Warnings
+
+### WR-01: `/api/session` reads an unbounded request body into memory (DoS surface)
+
+**File:** `bot/src/webhook.ts:49`
+**Issue:** `const raw = await req.text()` has no length cap before `parseFields` splits it. This is the one *unauthenticated, public* endpoint on the Worker: an attacker can POST up to Cloudflare's ingress body limit (tens of MB) of `&` or long values. `raw.split("&")` on a multi-MB body allocates millions of array elements plus Map entries — enough to blow the Worker isolate's 128 MB memory or CPU budget → 500s and isolate churn on a free-tier Worker. Telegram initData is a few hundred bytes; anything more is abuse.
+**Fix:**
+```ts
+async function handleSession(req: Request, env: Env): Promise {
+ if (req.method !== "POST") return new Response("Method not allowed", { status: 405 });
+ const raw = await req.text();
+ if (raw.length > 4096) {
+ // register in D-14 vocabulary: still category-only (no payload echo)
+ console.log("[initdata] rejected: malformed");
+ return new Response(JSON.stringify({ error: "malformed" }), { status: 413, headers: { "content-type": "application/json" } });
+ }
+ const result = await validateInitData(raw, env.BOT_TOKEN);
+ ...
+```
+(4 KB is generous — known-good real initData fixtures are ≪ 1 KB.)
+
+### WR-02: Missing `WEBHOOK_SECRET` silently disables webhook authentication (fail-open)
+
+**File:** `bot/src/webhook.ts:33-36`
+**Issue:** `webhookCallback(bot, "cloudflare-mod", undefined, undefined, env.WEBHOOK_SECRET)` passes the secret through, but grammY's check is opt-in per call: `compareSecretToken` in `node_modules/grammy/out/convenience/webhook.js:24-27` returns `true` for **every** request when `secretToken === undefined`. If `BOT_TOKEN` is armed as a wrangler secret but `WEBHOOK_SECRET` is forgotten (a plausible prod path — no deploy script runs `wrangler secret put`), Telegram still sends the header against a `setWebhook`-registered secret, yet the Worker accepts *any* unsigned POST to `/telegram/webhook` as auth: anyone can inject fabricated updates/spam, and this is the identity-carrying path Phase 4's Mini App flow will build on. Today's phase fails-anything-but-500 for missing secrets elsewhere; the one auth gate must not be the exception.
+**Fix:**
+```ts
+if (url.pathname === "/telegram/webhook") {
+ if (!env.WEBHOOK_SECRET) {
+ // fail closed: an unarmed secret must not silently arm the endpoint
+ return new Response("Server misconfigured", { status: 500 });
+ }
+ const bot = createBot(env);
+ ...
+}
+```
+
+### WR-03: D-07 rotate-on-new is not atomic — interleaved mints leave an orphan live token
+
+**File:** `bot/src/kv/sessions.ts:49-57`
+**Issue:** `mintSession` is get-pointer → delete-old-session → put-new-session → put-pointer. Cloudflare KV has no transactions and is eventually consistent, so two `POST `/api/session`` replays of the same valid initData can interleave: both read the same pointer, both mint, the loser's `session:` record is never deleted, and KV replication may serve a stale pointer across colos — both tokens are live for up to 900 s. The module comments claim the invariant is "structurally closed" (line 48: "one live token per chat") and `SR2` asserts "exactly one live record" — true only sequentially. Impact is bounded (any party able to mint within the 15-min freshness window already holds a valid identity by D-13's design), so this is not a privilege escalation, but the invariant the tests/docs pin is stronger than what KV delivers.
+**Fix (pick one and make the choice explicit in docs/tests):**
+- Reconcile after write: re-read `chat:` after both `put`s and `kv.delete` the pointer target if it is neither the new token nor already deleted.
+- Or downgrade the stated invariant truthfully: "rotation is best-effort under KV eventual consistency; every token dies within 900 s regardless", and note it in `sessions.ts`/`02-CONTEXT.md` so Phase 4 does not build a nonce-store assumption on top.
+
+## Info
+
+### IN-01: Dead code — unused `ParsedInitData` export, unreachable branch condition, unused read APIs
+
+**File:** `bot/src/security/initdata.ts:53-60` / `bot/src/kv/sessions.ts:52` / `bot/src/kv/sessions.ts:61-86`
+**Issue:** (a) `ParsedInitData` is exported and never imported anywhere — `validateInitData` never emits it (the file's own header comment doesn't use the shape either). (b) `previousToken !== token` at sessions.ts:52 can never be false — `token` is a fresh `crypto.randomUUID()` minted two lines above, so the guard is unreachable code dressed as a check. (c) `getTokenForChat` and `getClaimByToken` have no production caller in this phase; acceptable as the Phase 4 access-layer API, but only if that intent is stated.
+**Fix:** Delete `ParsedInitData` and the dead `previousToken !== token` half-branch (keep `if (previousToken !== null) { ... }`). Add a one-line comment over the two read helpers: "consumed by the Phase 4 Mini App flow" — or their first real consumer from that phase.
+
+### IN-02: Stale wrangler.jsonc comment contradicts the shipped namespace id
+
+**File:** `bot/wrangler.jsonc:2` (vs `:9`)
+**Issue:** Line 2 still says the KV id is "filled in plan 03 task 1 after `npx wrangler kv namespace create`", but line 9 already carries the real id. A future reader trusts the comment and tries to re-create/replace an existing namespace — the exact setup churn the comment was guarding against.
+**Fix:** Replace the comment with the post-fact record: `// chat_sessions namespace created via wrangler kv namespace create (id below is live).`
+
+### IN-03: Claimed 900 s freshness boundary is not actually tested
+
+**File:** `bot/test/initdata.test.mjs:133-150` / `bot/src/security/initdata.ts:185`
+**Issue:** Both the implementation comment ("899 ok / 900 ok / 901 stale") and the T4 plan-header claim the exact-900 case, but the suite only signs 899 s and 901 s fixtures. For a security-critical boundary (D-13), the middle point is the one that pins the comparator direction (`>` vs `>=`); the code's `>` matches the stated claim today, but nothing would catch a `>=` regression.
+**Fix:**
+```js
+test("T4 freshness boundary: age 900 s → ok:true (boundary is inclusive)", async () => {
+ const validateInitData = await loadValidator();
+ const now = Math.floor(Date.now() / 1000);
+ const res = await validateInitData(signedUser(now - 900), TEST_TOKEN);
+ assert.equal(res.ok, true, `expected ok:true at exactly 900 s, got ${JSON.stringify(res)}`);
+});
+```
+
+### IN-04: `.dev.vars` parser diverges from wrangler's dotenv semantics — and is duplicated in both scripts
+
+**File:** `bot/scripts/register-commands.mjs:29-46` / `bot/scripts/set-webhook.mjs:24-41`
+**Issue:** The hand-rolled parser keeps quotes and inline comments as part of the value (`BOT_TOKEN="123:ABC"` sends a token wrapped in literal quotes; `WEBHOOK_URL=... # comment)` mangles the URL), whereas wrangler itself parses `.dev.vars` dotenv-style with quotes stripped. A user following dotenv habits gets opaque 401s from `npm run bot:commands` while local `wrangler dev` works — the two runtimes disagree about the same file. Additionally the identical 18-line `readDevVars` exists in both scripts — the repo's first copy-paste of a secrets-handling helper, the worst place for implementations to drift.
+**Fix:** Strip surrounding quotes and trailing inline comments in one shared `bot/scripts/lib/dev-vars.mjs`, imported by both scripts; or better, call out the assumed format in `.dev.vars.example` (`unquoted KEY=value`, no inline comments).
+
+### IN-05: `handleSession` has no error handling around the mint — KV failure surfaces as an opaque 500 with no category log
+
+**File:** `bot/src/webhook.ts:47-64`
+**Issue:** `validateInitData` itself never throws (all parse paths are caught), but `mintSession` unhooks that guarantee: a KV write failure during `/api/session` propagates as an unhandled Worker exception — generic 500 page, no D-14 category entry, nothing distinguishing "storage down" from a crash in review/Tail logs. Not a correctness hazard (fail-closed), just an observability gap in the phase that defined the category vocabulary.
+**Fix:**
+```ts
+try {
+ const token = await mintSession(env.chat_sessions, { /* claim */ });
+ return new Response(JSON.stringify({ token }), { status: 200, ... });
+} catch {
+ console.log("[session] mint failed: storage"); // category-only (D-14)
+ return new Response(JSON.stringify({ error: "storage" }), { status: 503, headers: { "content-type": "application/json" } });
+}
+```
+
+---
+
+_Verified during review (not findings): validation vectors T1–T7 green; `tsc --noEmit` clean; `bot/.dev.vars` ignored and untracked; HMAC key derivation matches Telegram's official algorithm and the RFC-4231-pinned independent node:crypto path; every `kv.put` carries `expirationTtl`; the single `console.log` is category-only (D-14); no secrets or payload fields echoed in scripts or responses._
+
+_Reviewed: 2026-10-22T19:59:00Z_
+_Reviewer: OpenCode (gsd-code-reviewer)_
+_Depth: standard_
diff --git a/.planning/phases/02-bot-skeleton-initdata-security/02-VALIDATION.md b/.planning/phases/02-bot-skeleton-initdata-security/02-VALIDATION.md
new file mode 100644
index 0000000..e5f4b31
--- /dev/null
+++ b/.planning/phases/02-bot-skeleton-initdata-security/02-VALIDATION.md
@@ -0,0 +1,81 @@
+---
+phase: 2
+slug: bot-skeleton-initdata-security
+status: ready
+nyquist_compliant: true
+wave_0_complete: false
+created: 2026-10-22
+---
+
+# Phase 2 — Validation Strategy
+
+> Per-phase validation contract for feedback sampling during execution.
+
+---
+
+## Test Infrastructure
+
+| Property | Value |
+|----------|-------|
+| **Framework** | Hand-rolled node runner (D-12) — `node:assert`, zero extra deps, `theme/scripts/audit.test.mjs` shape |
+| **Config file** | none — plain `.mjs` (Wave 0 creates) |
+| **Quick run command** | `npm test -w bot` |
+| **Full suite command** | `npm test -w bot` (single runner covers vectors + privacy gates) |
+| **Estimated runtime** | ~2 seconds (pure crypto + mocks, no network) |
+
+---
+
+## Sampling Rate
+
+- **After every task commit:** Run `npm test -w bot`
+- **After every plan wave:** Run `npm test -w bot` + `tsc --noEmit` (bot workspace)
+- **Before `/gsd-verify-work`:** Full suite must be green
+- **Max feedback latency:** 5 seconds
+
+---
+
+## Per-task Verification Map
+
+| task ID | Plan | Wave | Requirement | Threat Ref | Secure Behavior | Test Type | Automated Command | File Exists | Status |
+|---------|------|------|-------------|------------|-----------------|-----------|-------------------|-------------|--------|
+| {N}-01-01 | 01 | 1 | PRIV-02, D-08/D-11/D-12/D-15/D-17 | T-02-03 / T-02-04 | no committed secrets; TTL-only type surface scaffold | unit (harness + vector stubs live) | `npm test -w bot` | ❌ W0 | ⬜ pending |
+| {N}-01-02 | 01 | 1 | Security (SC3), ONBD-03-adjacent | T-02-01 / T-02-02 | tampered/stale/replayed/malformed rejected; known-good HMAC passes | unit (vectors) | `npm test -w bot` | ❌ W0 (stubbed in 01-01, filled here) | ⬜ pending |
+| {N}-02-01 | 02 | 2 | PRIV-01/D-05 | T-02-04 | strings module has en + ms, no violation classes; TTL-only log categories (D-14) | unit | `npm test -w bot` | ❌ W0 | ⬜ pending |
+| {N}-02-02 | 02 | 2 | ONBD-01, D-01..D-05 | — | handlers lean, no keyboards (D-02); alias co-routing (D-04); setMyCommands default+ms (D-03/D-04) | unit | `npm test -w bot` | ✅ (from 02-01 forward) | ⬜ pending |
+| {N}-02-02-manual | 02 | 2 | ONBD-01 | — | real Telegram client: /start, /help, /privacy, aliases | manual checkpoint | — | — | ⬜ pending |
+| {N}-03-01 | 03 | 3 | D-06/D-07/D-08, PRIV-02 | T-02-05 / T-02-06 | mint requires validated initData; rotate-on-new; claim is D-08-minimal; expirationTtl on every put | unit (mock KV) | `npm test -w bot` | ✅ | ⬜ pending |
+| {N}-03-02 | 03 | 3 | D-16 deploy gate | T-02-03 | wrangler dry-run clean; .dev.vars ignored; secrets not in git | integration (dry-run) + manual checklist | `npx wrangler deploy --dry-run && npm test -w bot` | ✅ | ⬜ pending |
+
+*Status: ⬜ pending · ✅ green · ❌ red · ⚠️ flaky*
+
+---
+
+## Wave 0 Requirements
+
+- [ ] `bot/test/runner.mjs` — minimal hand-rolled harness (theme/scripts/audit.test.mjs shape, node:assert)
+- [ ] `bot/test/initdata.test.mjs` — vector suite skeleton (known-good + tampered + stale + replay cases)
+- [ ] `bot/.gitignore` (+ `.dev.vars.example`) — ships in the same commit as the workspace init (D-17)
+- [ ] `npm i -w bot` — workspace package install (grammy, wrangler, typescript, workers-types, @types/node)
+
+---
+
+## Manual-Only Verifications
+
+| Behavior | Requirement | Why Manual | Test Instructions |
+|----------|-------------|------------|-------------------|
+| /start /help /privacy + /mula /privasi reply correctly in real Telegram | ONBD-01, PRIV-01 | Requires a live Telegram client + deployed bot token; Bot UI cannot be driven from the runner | 1) `npm run deploy` (D-16) 2) /start the real bot → intro + menu 3) /help → menu matches D-03 4) /privacy → truthful copy 5) /mula /privasi → same flows 6) non-en client → ms menu (covers setMyCommands scope) |
+| Webhook 401 on bad secret-token header | V4/V2 security gate | grammY's verified constant-time check is library behavior, not our code | `curl -X POST $URL/telegram/webhook -d '{}'` (no header) → 401; with wrong value → 401 |
+| Deploy smoke + KV spot-check | PRIV-02, D-16 | Live storage inspection | after deploy: `npx wrangler kv key list --namespace chat_sessions` (via dev session) — only TTL-bounded keys; no coordinate values; keys expire after 900s |
+
+---
+
+## Validation Sign-Off
+
+- [x] All tasks have `` verify or Wave 0 dependencies
+- [x] Sampling continuity: no 3 consecutive tasks without automated verify
+- [x] Wave 0 covers all MISSING references
+- [x] No watch-mode flags
+- [x] Feedback latency < 5s
+- [x] `nyquist_compliant: true` set in frontmatter
+
+**Approval:** pending
diff --git a/.planning/phases/02-bot-skeleton-initdata-security/02-VERIFICATION.md b/.planning/phases/02-bot-skeleton-initdata-security/02-VERIFICATION.md
new file mode 100644
index 0000000..e8974ff
--- /dev/null
+++ b/.planning/phases/02-bot-skeleton-initdata-security/02-VERIFICATION.md
@@ -0,0 +1,135 @@
+---
+phase: 02-bot-skeleton-initdata-security
+verified: 2026-10-22T21:05:00Z
+status: passed
+score: 4/4 must-haves verified
+overrides_applied: 0
+re_verification:
+ previous_status: none
+ previous_score: n/a
+ gaps_closed: []
+ gaps_remaining: []
+ regressions: []
+---
+
+# Phase 2: Bot Skeleton + initData Security Verification Report
+
+**Phase Goal:** Users can reach araharah through Telegram with a working command surface, and the backend's identity + privacy architecture is provably sound from the first deploy — the initData validator is the first backend artifact.
+**Verified:** 2026-10-22T21:05Z
+**Status:** passed
+**Re-verification:** No — initial verification (codebase + live deployment)
+
+## Goal Achievement
+
+### Observable Truths
+
+| # | Truth (ROADMAP SC) | Status | Evidence |
+|---|--------------------|--------|----------|
+| 1 | SC1/ONBD-01: /start gives a concise explanation plus an always-available command menu (/help, /privacy) that answers correctly | ✓ VERIFIED | Handlers `bot.command("start"/"mula", …)` reply `strings.startIntro` (one clinical sentence, text-only, no keyboard — CS4 grep gate green); live Telegram read-backs: `getMyCommands` (default) and `getMyCommands?language_code=ms` both return **exactly** `help` + `privacy`, `getChatMenuButton` → `commands`; user real-client walkthrough a–e all green and APPROVED (`02-03-APPROVAL.md`) |
+| 2 | SC2/PRIV-01: /privacy gives an honest visible statement of what araharah stores / never stores | ✓ VERIFIED | `privacy.ts` builds `privacyStored` + `privacyNotStored` from `strings.ts`; copy names exactly user id, chat id, language, ~15-min TTL — matching `sessions.ts` `SessionClaim {user_id, chat_id, issued_at, language_code}` and `SESSION_TTL_SECONDS=900`; no 5th claim field in code; user observed block as written (approval check c) |
+| 3 | SC3: tampered, stale, and replayed initData rejected with CI test vectors; known-good HMAC passes; only validated identities get a one-time token | ✓ VERIFIED | `npm test -w bot` → **34 passed, 0 failed** (T1–T7 security vectors incl. tamper/stale/899/901 boundary/order/malformed + RFC-4231 pin; WK1–WK5; sessions SM1/SM2/SR1–SR3; privacy PG1/PG2). Mint path is *only* after `ok:true` in `webhook.ts` (single `mintSession` call). Replay closes via D-07 rotate-on-new (CONTEXT decision; re-mint cannot yield an extra token — accepted locked decision, WR-03 invariant documented honestly) |
+| 4 | SC4/PRIV-02: storage spot-check shows TTL-only KV, zero persistent coordinates or profiles | ✓ VERIFIED | Live `wrangler kv key list --namespace-id 59f58a54…` → `[]` (empty = TTL-expired privacy-by-construction); every `kv.put` in `sessions.ts` carries `expirationTtl: SESSION_TTL_SECONDS` on the call line (PG1 grep test green); `wrangler.jsonc` has exactly ONE kv binding (`chat_sessions`), no D1/R2/DO anywhere; claim key-set equals D-08 enforced by SM1 |
+
+**Score:** 4/4 truths verified
+
+### Post-Review Hardening (WR-01/02/03) — Live Verification
+
+| Check | Status | Evidence |
+|-------|--------|----------|
+| Deployed version includes all 3 fix commits | ✓ | Commits `8995498`/`3ea1fdd`/`af691ae` at 12:17–12:21 UTC; HEAD = `af691ae`; latest active deployment `b6ba6e58-37dd-4aa1…` created 12:22:03 UTC (after final commit — `wrangler deployments list`) |
+| WR-01 live (4 KB body cap) | ✓ | `POST /api/session` with 5 KB body → **413**; tiny garbage → 401 `{"error":"malformed"}` |
+| WR-02 fail-closed secret guard | ✓ | Code verified at `webhook.ts:50-53` (constant 500 before grammY accept-all path); WK5 test green; not live-testable read-only (would require rotating secrets — timing evidence + 413 smoke confirm post-fix bundle deployed) |
+| WR-03 honest invariant | ✓ | `sessions.ts` header documents best-effort rotate + reconciliation sweep; SR3 asserts the honest KV-limits outcome; IN-01b dead branch removed |
+
+### Deferred Items (Step 9b filter of review findings)
+
+| # | Item | Addressed In | Evidence |
+|---|------|-------------|----------|
+| 1 | Stale wrangler.jsonc comment (line 2: "filled in plan 03") contradicting the live id on line 9 (REVIEW IN-02, INFO) | Phase 7 | `02-REVIEW-FIX.md`: "Out of scope (Phase 7 audit): IN-01(a), IN-02, IN-03, IN-04, IN-05" — doc-comment hygiene, zero functional impact |
+
+### Required Artifacts
+
+| Artifact | Expected | Status | Details |
+|----------|----------|--------|---------|
+| `bot/src/security/initdata.ts` | validateInitData + rejection classes, no grammy dep | ✓ VERIFIED | 215 lines; SubtleCrypto HMAC, constant-time hex compare, "WebAppData" salt; consumed by `webhook.ts` |
+| `bot/src/kv/sessions.ts` | TTL-only KV layer, rotate-on-new | ✓ VERIFIED | 122 lines; 2 puts, both with `expirationTtl`; read helpers noted as Phase 4 API (IN-01c) |
+| `bot/src/webhook.ts` | Worker entry: webhook route + /api/session gate | ✓ VERIFIED | Two routes only + 404; WR-01 cap; WR-02 fail-closed; D-14 category-only logs (4 console lines, all category-only) |
+| `bot/src/i18n/strings.ts` + `bot/src/bot/{bot.ts,commands/*}` | Copy source of truth, en+ms, text-only handlers | ✓ VERIFIED | WIRED: commands import strings; bot.ts registers 5 commands; live menu read-back matches helpMenu |
+| `bot/src/config.ts` | Env contract | ✓ VERIFIED | BOT_TOKEN/WEBHOOK_SECRET/chat_sessions; REQUIRED comment added in WR-02 fix |
+| `bot/test/{harness,runner}.mjs` | Zero-dep D-12 harness | ✓ VERIFIED | `test: "node test/runner.mjs"` — ran clean, 34/0, `devDependencies` carry zero test libs |
+| `bot/test/{initdata,sessions,privacy,commands,webhook}.test.mjs` | Vector suites | ✓ VERIFIED | 34 cases green incl. T1–T7, WK1–WK5 (webhook suite added by review fix `8995498`) |
+| `bot/scripts/{register-commands,set-webhook}.mjs` | Menu registration + setWebhook w/ secret_token | ✓ VERIFIED | Descriptions derived from helpMenu (no drift); secrets read from `.dev.vars`, never echoed |
+| `bot/wrangler.jsonc` | main src/webhook.ts + chat_sessions binding (live id) | ✓ VERIFIED | id `59f58a54…` live (matches APPROVAL record + deployment) |
+| `.dev.vars` hygiene | Gitignored, untracked | ✓ VERIFIED | `git check-ignore` ok; `git ls-files` shows only `.dev.vars.example` |
+
+### Key Link Verification
+
+| From | To | Via | Status | Details |
+|------|----|----|--------|---------|
+| webhook.ts | initdata.ts | `validateInitData` call → 401/413 on rejection, mint on ok | ✓ WIRED | Lines 99–113; live 401 smoke confirms |
+| webhook.ts | sessions.ts | `mintSession` only after ok:true | ✓ WIRED | Single mint path; WK1 asserts exactly two 900s KV writes |
+| webhook.ts | bot.ts / grammy | `webhookCallback(bot,"cloudflare-mod",undefined,undefined,env.WEBHOOK_SECRET)` | ✓ WIRED | Live unsigned/wrong-secret webhook → 401 both |
+| commands/*.ts | strings.ts | copy imported, no inline copy | ✓ WIRED | start/help/privacy all import `strings`; reply builders pure |
+| register-commands.mjs | strings.ts | menu descriptions derived from helpMenu | ✓ WIRED | Live getMyCommands text matches strings exactly |
+| sessions.ts | KVNamespace.put | every put carries expirationTtl | ✓ WIRED | PG1/PG2/SM2 green; live KV list empty post-TTL |
+
+### Data-Flow Trace (Level 4)
+
+| Artifact | Data Variable | Source | Produces Real Data | Status |
+|----------|--------------|--------|-------------------|--------|
+| webhook.ts handleSession | initData string | HTTP request body | Yes — validated against real bot-token HMAC | ✓ FLOWING |
+| privacy command | locale/strings | ctx.from.language_code → pickLocale | Yes (live user confirmed) | ✓ FLOWING |
+
+### Behavioral Spot-Checks
+
+| Behavior | Command | Result | Status |
+|----------|---------|--------|--------|
+| Full test suite | `npm test -w bot` | `[runner] 34 passed, 0 failed`, exit 0 | ✓ PASS |
+| Typecheck | `npm run bot:typecheck` | exit 0 | ✓ PASS |
+| Webhook auth (unsigned / wrong header) | live POST /telegram/webhook | 401 / 401 | ✓ PASS |
+| /api/session garbage | live POST | 401 `{"error":"malformed"}` | ✓ PASS |
+| /api/session 5 KB body (WR-01) | live POST | 413 | ✓ PASS |
+| GET /api/session | live | 405 | ✓ PASS |
+| Unknown route | live | 404 | ✓ PASS |
+| Command menu (default + ms) | live getMyCommands ×2 | exactly /help + /privacy in both languages | ✓ PASS |
+| Menu button | live getChatMenuButton | type `commands` | ✓ PASS |
+| Webhook armed | live getWebhookInfo | url `/telegram/webhook`, pending_update_count 0, `allowed_updates:["message"]` | ✓ PASS |
+| KV TTL-only spot-check | live `wrangler kv key list` | `[]` (empty = all sessions expired ≤900s) | ✓ PASS |
+| Deployed = HEAD (post-fix bundle) | `wrangler deployments list` + git log | b6ba6e58 (12:22:03Z) created after af691ae (12:21:11Z); current deployments list shows b6ba6e58 active at 100% | ✓ PASS |
+
+### Requirements Coverage
+
+| Requirement | Source Plan | Description | Status | Evidence |
+|-------------|------------|-------------|--------|----------|
+| ONBD-01 | 02-01, 02-03 | Open bot, /start gives concise explanation + always-available command menu | ✓ SATISFIED | Handlers + strings verified; live menu lists exactly /help /privacy both scopes; user-approved walkthrough (SC1) |
+| PRIV-01 | 02-01, 02-03 | Privacy promise visible in-product: /privacy command | ✓ SATISFIED (phase half) | /privacy + /privasi live-observed; noted: "trip ended, data deleted" messaging half completes in Phase 6 per REQUIREMENTS.md:127 |
+| PRIV-02 | 02-01, 02-02 | No persistent per-user history/profile — TTL-only ephemeral state | ✓ SATISFIED | Live KV `[]`; TTL-only on all write paths (PG1/PG2/SM tests); one binding in wrangler.jsonc; no other storage code in bot/src |
+
+All phase requirement IDs (ONBD-01, PRIV-01, PRIV-02) appear in plan frontmatter and are mapped to Phase 2 in REQUIREMENTS.md:97-99 — no orphaned requirements. Roadmap Phase 2 defines exactly 4 success criteria (device-matrix criterion belongs to Phase 3's SC5) — all 4 covered above.
+
+### Anti-Patterns Found
+
+| File | Line | Pattern | Severity | Impact |
+|------|------|---------|----------|--------|
+| bot/wrangler.jsonc | 2 | Stale "KV_ID_PLACEHOLDER" comment vs live id on line 9 | ℹ️ Info | Doc-only, deferred to Phase 7 (REVIEW IN-02) |
+| bot/src/security/initdata.ts | 53-60 | `ParsedInitData` exported but unused | ℹ️ Info | Type-only export, deliberate; documented in REVIEW IN-01(a) |
+| — (everything else) | — | TODO/FIXME/placeholder/console-echo of secrets/empty implementations: full grep of src/, scripts/, test/ returned **zero** | — | — |
+
+The `getWebhookInfo` `last_error` timestamp (11:18:20 UTC) predates the final BOT_TOKEN secret re-upload (11:18:25 UTC deployment from the walkthrough remediation) and the user's subsequent live `/start` approval; `pending_update_count` is 0. Known resolved history, not a finding.
+
+### Human Verification Required
+
+**None outstanding.** The human-only gates of this phase were already resolved and are recorded as evidence:
+
+- **Real-client walkthrough a–e** (D-16 blocking gate): user performed 2026-10-22 and said *"it works! Menu or / has /help and /privacy! approved!"* — `02-03-APPROVAL.md` (cross-checked live via Telegram API read-backs performed during this verification).
+- Secret provisioning (BotFather token, wrangler secrets): resolved during deploy chain; runtime behavior live-verified by smokes.
+
+### Gaps Summary
+
+No gaps. All four roadmap success criteria verified against code, tests, and the live deployment; the code-review's 3 warnings are fixed and the fix bundle is what is live (timing + WR-01 live smoke). The one un-replicable human gate (real Telegram client) is user-approved with recorded evidence.
+
+---
+
+_Verified: 2026-10-22T21:05Z_
+_Verifier: OpenCode (gsd-verifier)_
+_Verification basis: local code at af691ae + live deployment b6ba6e58; all Cloudflare/Telegram state touched read-only._
diff --git a/.planning/phases/03-mini-app-shell-geolocation/03-01-PLAN.md b/.planning/phases/03-mini-app-shell-geolocation/03-01-PLAN.md
new file mode 100644
index 0000000..f7acbea
--- /dev/null
+++ b/.planning/phases/03-mini-app-shell-geolocation/03-01-PLAN.md
@@ -0,0 +1,824 @@
+---
+phase: 03-mini-app-shell-geolocation
+plan: 01
+type: execute
+wave: 1
+depends_on: []
+files_modified:
+ - web/package.json
+ - web/tsconfig.json
+ - web/vite.config.ts
+ - web/index.html
+ - web/test/runner.mjs
+ - web/test/harness.mjs
+ - bot/src/kv/location-pin.ts
+ - bot/src/bot/handlers/location-fallback.ts
+ - bot/src/bot/bot.ts
+ - bot/src/i18n/strings.ts
+ - bot/src/webhook.ts
+ - bot/test/location-pin.test.mjs
+ - bot/test/privacy.test.mjs
+ - package.json
+autonomous: true
+requirements: [GEO-02, GEO-03]
+must_haves:
+ decisions_implemented:
+ - "D-01: TypeScript + Vite frontend, no UI framework, MapLibre GL JS v6 bundled — the workspace foundation Phases 4–6 build on"
+ - "D-02: app source in a `web/` npm workspace; theme consumed as a build-time dependency, not CDN"
+ truths:
+ - "`web/` exists as an npm workspace with Vite + TypeScript configured and `npm test -w web` runs"
+ - "A dev-signed initData string can be produced by a test helper (the keystone for every later web test)"
+ - "`GET /api/location` validates a Bearer session token and returns only that token's own chat pin, or 401"
+ - "`POST /api/location-fallback` validates the same token and asks the bot to send the location keyboard into THAT claim's chat, in the claim's language"
+ - "The bot sends a ReplyKeyboardMarkup with request_location:true and records the shared pin in TTL-only KV"
+ - "Every KV write in the new location-pin module carries an explicit expirationTtl on the same line"
+ artifacts:
+ - path: "web/package.json"
+ provides: "@araharah/web workspace manifest with test/typecheck/dev/build scripts"
+ - path: "web/tsconfig.json"
+ provides: "DOM-targeted strict TS config mirroring bot/tsconfig.json flags (+ resolveJsonModule for the basemap style import)"
+ - path: "web/test/runner.mjs"
+ provides: "hand-rolled zero-dependency test runner (copied from bot/test/runner.mjs)"
+ - path: "web/test/harness.mjs"
+ provides: "zero-dependency test registry (copied from bot/test/harness.mjs)"
+ - path: "bot/src/kv/location-pin.ts"
+ provides: "TTL-only KV location-pin layer (put/get/delete by chat id)"
+ - path: "bot/src/bot/handlers/location-fallback.ts"
+ provides: "GEO-02 keyboard sender + message.location receiver"
+ - path: "bot/src/webhook.ts"
+ provides: "GET /api/location relay + POST /api/location-fallback keyboard trigger, both Bearer-gated"
+ key_links:
+ - from: "bot/src/webhook.ts"
+ to: "bot/src/kv/sessions.ts"
+ via: "getClaimByToken(env.chat_sessions, token) — derives chat_id from the CLAIM, never from a client-supplied id"
+ pattern: "getClaimByToken"
+ - from: "bot/src/webhook.ts"
+ to: "bot/src/bot/handlers/location-fallback.ts"
+ via: "handleLocationFallback calls requestLocationFallback(createBot(env), claim.chat_id, pickLocale(claim.language_code)) — the ONLY caller, so the export is never dead code"
+ pattern: "requestLocationFallback"
+ - from: "bot/src/bot/handlers/location-fallback.ts"
+ to: "bot/src/kv/location-pin.ts"
+ via: "writePin(kv, chatId, {latitude, longitude}) after message.location arrives"
+ pattern: "writePin"
+---
+
+
+Stand up the `web/` npm workspace shell (D-01, D-02) with the Wave-0 test runner, and build the
+server half of GEO-02: the TTL-only location-pin KV module, the bot's chat-keyboard fallback handler,
+and BOTH Worker routes that relay a chat-shared pin to the open Mini App — `GET /api/location`
+(the poll) and `POST /api/location-fallback` (the app→bot keyboard trigger).
+
+Purpose: GEO-02 is impossible without a chat→WebView relay — Telegram has no push channel from
+chat to WebView, so the bot must write the pin to KV and the app must poll an authenticated endpoint
+(RESEARCH §Pattern 3). The trigger direction matters just as much: the denied card's ASK VIA CHAT
+CTA (plan 03-03) and the Desktop leg of the device matrix (plan 03-04) both need a route that makes
+the bot send the keyboard. Both directions land here.
+
+Output: `web/` workspace + test runner; `bot/src/kv/location-pin.ts`; `bot/src/bot/handlers/location-fallback.ts`;
+`GET /api/location` + `POST /api/location-fallback` routes; `bot/test/location-pin.test.mjs`; extended `privacy.test.mjs`.
+
+
+
+@$HOME/.config/opencode/get-shit-done/workflows/execute-plan.md
+@$HOME/.config/opencode/get-shit-done/templates/summary.md
+
+**Commit discipline:** commit after EACH task (one commit per task, atomic). If task 3 overruns
+its context budget, split it rather than pushing through — this plan touches 14 files across 3 tasks,
+which is above the 10-file warning line and is only safe because the code is pre-specified near-verbatim.
+A task that needs more than ~7 files should be decomposed, not absorbed.
+
+
+
+@AGENTS.md
+@.planning/phases/03-mini-app-shell-geolocation/03-CONTEXT.md
+@.planning/phases/03-mini-app-shell-geolocation/03-RESEARCH.md
+@.planning/phases/03-mini-app-shell-geolocation/03-PATTERNS.md
+@bot/src/webhook.ts
+@bot/src/kv/sessions.ts
+@bot/src/bot/bot.ts
+@bot/src/bot/commands/start.ts
+@bot/src/i18n/strings.ts
+@bot/src/config.ts
+@bot/test/privacy.test.mjs
+@bot/test/commands.test.mjs
+@bot/test/webhook.test.mjs
+@bot/test/runner.mjs
+@bot/test/harness.mjs
+@bot/package.json
+@bot/tsconfig.json
+@package.json
+@theme/package.json
+
+
+From `bot/src/kv/sessions.ts` (the contract `location-pin.ts` mirrors):
+
+```typescript
+export const SESSION_TTL_SECONDS = 900;
+
+export interface SessionClaim {
+ user_id: number;
+ chat_id: number;
+ issued_at: number; // seconds-since-epoch
+ language_code: string | null;
+}
+
+interface KvLike {
+ get(key: string): Promise;
+ put(key: string, value: string, options: { expirationTtl: number }): Promise;
+ delete(key: string): Promise;
+}
+
+export async function mintSession(kv: MintSessionKv, claim: SessionClaim): Promise;
+export async function getClaimByToken(kv: MintSessionKv, token: string): PromiseSessionClaim | null;
+export async function getTokenForChat(kv: MintSessionKv, chatId: number): Promise;
+```
+
+From `bot/src/config.ts`:
+```typescript
+export interface Env {
+ BOT_TOKEN: string;
+ WEBHOOK_SECRET: string;
+ chat_sessions: KVNamespace;
+}
+```
+
+From `bot/src/webhook.ts` — the dispatch shape to extend:
+```typescript
+export default {
+ async fetch(req: Request, env: Env, _ctx: ExecutionContext): Promise {
+ const url = new URL(req.url);
+ if (url.pathname === "/telegram/webhook") { /* ... */ }
+ if (url.pathname === "/api/session") { return handleSession(req, env); }
+ return new Response("Not found", { status: 404 });
+ },
+} satisfies ExportedHandler;
+```
+
+From `bot/test/webhook.test.mjs` — the dev-signing helper shape (copy this, do NOT reimplement HMAC):
+```javascript
+function dataCheckString(fields) {
+ return fields
+ .filter(([key]) => key !== "hash" && key !== "signature")
+ .slice()
+ .sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0))
+ .map(([key, value]) => `${key}=${value}`)
+ .join("\n");
+}
+function telegramSecretKey(botToken) {
+ return createHmac("sha256", "WebAppData").update(botToken, "utf8").digest();
+}
+function buildInitData(fields) {
+ const hash = createHmac("sha256", telegramSecretKey(TEST_TOKEN))
+ .update(dataCheckString(fields), "utf8")
+ .digest("hex");
+ const wire = fields.map(([key, value]) => `${key}=${encodeURIComponent(value)}`).join("&");
+ return `${wire}&hash=${hash}`;
+}
+```
+
+
+
+
+
+
+ task 1: `web/` workspace scaffold + Wave-0 test runner (D-01, D-02, D-11, D-14)
+ web/package.json, web/tsconfig.json, web/vite.config.ts, web/index.html, web/test/runner.mjs, web/test/harness.mjs, package.json
+
+ - `package.json` (root — see current workspaces array and bot:* delegate script block)
+ - `bot/package.json` (the workspace manifest shape to mirror exactly)
+ - `bot/tsconfig.json` (the strictness flags to preserve)
+ - `bot/test/runner.mjs` and `bot/test/harness.mjs` (copy verbatim)
+ - `.planning/phases/03-mini-app-shell-geolocation/03-RESEARCH.md` §Standard Stack (pinned versions)
+
+
+**Step A — `web/package.json`.** Create `web/package.json` mirroring `bot/package.json` exactly in shape:
+
+```json
+{
+ "name": "@araharah/web",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "test": "node test/runner.mjs",
+ "typecheck": "tsc --noEmit",
+ "dev": "vite",
+ "build": "vite build",
+ "capture": "node scripts/capture.mjs"
+ },
+ "dependencies": {
+ "maplibre-gl": "^6.15.0",
+ "@araharah/theme": "*"
+ },
+ "devDependencies": {
+ "vite": "^8.3.5",
+ "typescript": "^5.9.3",
+ "playwright": "^1.50.0"
+ }
+}
+```
+Pinned versions come from RESEARCH §Standard Stack (maplibre-gl 6.15.0, vite 8.3.5, typescript 5.9.3 — do NOT bump to 7.x, playwright ^1.50.0).
+
+**Do NOT add `@types/telegram-web-app`.** RESEARCH §Don't Hand-Roll recommends it for the *full* `WebApp` surface, but this phase consumes exactly 12 members behind one narrow hand-rolled interface (`TelegramWebAppLike` in `web/src/telegram/webapp.ts`, plan 03-02 task 3). A narrow interface is required here because every test fake and every harness mock is a plain object literal matching it — pulling in the 500-member official `WebApp` type would force each fake to satisfy ~50 unimplemented members. This retires RESEARCH assumption A2 by construction: there is no upstream typing to drift from. Do not add the dependency later "for completeness."
+
+**Step B — `web/tsconfig.json`.** Copy `bot/tsconfig.json` verbatim and change exactly THREE keys (everything else stays byte-identical):
+- `"lib": ["ES2023", "DOM", "DOM.Iterable"]` (was `["ES2023"]`)
+- `"types": ["vite/client"]` (was `["@cloudflare/workers-types"]`)
+- **`"resolveJsonModule": true` (ADDED — not present in `bot/tsconfig.json`)**. This third key is pre-authorized and REQUIRED: plan 03-03 task 2 imports `@araharah/theme/basemap/liberty-rams.json` as a module (`import ramsStyle from "@araharah/theme/basemap/liberty-rams.json"`), which is a TypeScript error without it. Do NOT add `esModuleInterop`/`allowSyntheticDefaultImports` — Vite's bundler resolution + `resolveJsonModule` covers the default import.
+
+Keep every other flag byte-identical: `"target": "ES2023"`, `"module": "ESNext"`, `"moduleResolution": "bundler"`, `"strict": true`, `"noUncheckedIndexedAccess": true`, `"verbatimModuleSyntax": true`, `"allowImportingTsExtensions": true`, `"noEmit": true`, `"skipLibCheck": true`, `"forceConsistentCasingInFileNames": true`, `"include": ["src"]`. Do NOT add `"jsx"` (no framework, D-01).
+
+**Step C — `web/vite.config.ts`.**
+```typescript
+import { defineConfig } from "vite";
+
+export default defineConfig({
+ base: "/",
+ build: {
+ outDir: "dist",
+ emptyOutDir: true,
+ target: "es2022",
+ },
+ server: {
+ port: 5173,
+ strictPort: true,
+ },
+});
+```
+
+**Step D — `web/index.html`.** Minimal shell entry. Exact viewport meta (viewport-fit=cover is the safe-area enabler, UI-SPEC §Safe-Area Handling):
+```html
+
+
+
+
+
+ araharah
+
+
+
+
+
+
+```
+Do NOT add a `
+```
+**Mock `initData`.** The `POST /api/session` stub never reaches the Worker, so `installMockWebApp`'s `initData` only has to be a non-empty, correctly-shaped string — the harness does NOT need a valid HMAC. Have the mock emit `user={"language_code":"","id":1,"first_name":"Harness"}&auth_date=1&query_id=h&hash=harness` (URL-encoded per Telegram's wire format) so `localeFromInitData` in `main.ts` exercises its real parsing path instead of always falling back to `"en"`. The `?locale=` parameter above overrides the language_code, so a future ms capture needs no code change.
+
+Note in `web/index.html`'s comment that no `localStorage.setItem`/`sessionStorage.setItem` may appear here — the harness profile lives in a module-scope global (`window.__ARAHARAH_MOCK__`), never in storage (D-07).
+
+**`web/scripts/capture.mjs`.** Copy `theme/scripts/screenshot.mjs` and adapt. Structure:
+```javascript
+#!/usr/bin/env node
+/*
+ * capture.mjs — Phase-3 capture harness (D-11, DSGN-02, GEO-03 runtime probe).
+ * Extends theme/scripts/screenshot.mjs (Phase 1 precedent) rather than
+ * replacing it: same MIN_BYTES blank bar, same docroot-guarded static
+ * server, same READY-signal console race, same FAILED/MISSING/UNDERSIZED
+ * report + exit(1).
+ *
+ * Docroot is web/dist-HARNESS (the --mode harness build), NOT web/dist.
+ * Reason: web/index.html's entire Mock-Telegram profile block — the mock
+ * installer and the /api/session + /api/location + /api/location-fallback
+ * fetch stubs — sits inside `if (__ARAHARAH_HARNESS__)`. `vite build` replaces
+ * that identifier with `false`, so the branch is dead-code-eliminated and
+ * web/dist/index.html carries none of it (BA5 scans the dist HTML too).
+ * Serving web/dist would yield no window.Telegram, a real POST /api/session
+ * with no Worker behind it, the denied card, and a permanent
+ * ARAHARAH-LIVE timeout.
+ *
+ * Wait signals: RAMS-BASEMAP-READY (earlier gate), then ARAHARAH-LIVE.
+ *
+ * Outputs (web/screenshots/ — regenerated, gitignored):
+ * shell-boot-1.png boot stage 1 (validating session card over live map)
+ * shell-boot-2.png boot stage 2 (acquiring location card)
+ * shell-live-light.png LIVE state, Telegram light theme
+ * shell-live-dark.png LIVE state, Telegram dark theme (PX-02)
+ * shell-denied.png denied card + ASK VIA CHAT (GEO-02 entry point)
+ * shell-chat-pin.png LIVE via the chat-pin relay (?pin=1, stubbed /api/location)
+ * gallery-compare.png DSGN-02 side-by-side vs navigate_map_overview/screen.png
+ *
+ * Also runs the GEO-03 runtime probe in-page after boot-to-live and fails
+ * the run on any non-empty storage surface.
+ */
+```
+Implementation specifics:
+
+1. **Build the harness bundle first, and PROVE it is the harness bundle.** `execSync("npm run build -w theme && npm run build -w web && npm run build:harness -w web", { cwd: repoRoot })`. After the build, assert `web/dist-harness/index.html` exists AND that the harness bundle actually contains `installMockWebApp` (a cheap `grep -q` over `web/dist-harness/assets/*.js`). If either check fails, abort immediately with "harness build did not include the Mock-Telegram installer — the capture would race forever" rather than letting the READY race time out 30s later with no diagnosis. The production `web/dist` must NOT contain it (BA5, task 1) — assert both directions.
+2. **Server.** Copy `serveTheme()` verbatim, changing `THEME` to `DIST = resolve(SCRIPTS, "..", "dist-harness")` and keeping the WR-05 docroot guard. Extend the `MIME` map to include `.mjs`, `.map`, `.webmanifest`.
+3. **Contexts.** One chromium browser; per capture, `browser.newContext({ viewport: { width: 390, height: 844 }, colorScheme, deviceScaleFactor: 2 })`.
+4. **Geolocation grant.** `await context.grantPermissions(["geolocation"], { origin: base });` and `await context.setGeolocation({ latitude: 3.1513, longitude: 101.6958, accuracy: 12 });` before `page.goto` — for every profile EXCEPT `tdesktop`.
+5. **READY race.** Attach `page.on("console", m => { if (m.text().startsWith("RAMS-BASEMAP-READY")) basemapReady = true; if (m.text().startsWith("ARAHARAH-LIVE")) liveReady = true; })` then `await Promise.race([liveReady, timeout(30000)])` per the Phase-1 pattern. **The `?mock=tdesktop` DENIED leg gets its own dedicated race against the `.card` + `[data-state="denied"]` strip lamp (see leg 6), NOT `ARAHARAH-LIVE` and NOT the 30s timeout** — `chain.ts` LINK 3 deliberately hard-codes `pollChatPin(token, { timeoutMs: 60000 })` before it returns `denied`, so the denied card first renders at roughly **60 seconds**. Race that leg against `timeout(90000)` with the reason recorded in this header comment (`DENIED_LEG_TIMEOUT_MS = 90000; // LINK 3's 60s pre-denial chat-pin poll must elapse first`). Any other leg that waits on a signal must not silently sit under 60s. **A timeout is a hard failure with a diagnostic, not a silent skip** — print which signals fired (`RAMS-BASEMAP-READY` yes/no, `ARAHARAH-LIVE` yes/no) plus the last 5 console lines, so a future regression points at the missing piece immediately.
+6. **The seven captures**, in order:
+ - `?mock=android` immediately after goto → `shell-boot-1.png` (stage 1 card).
+ - `?mock=android` after 400ms → `shell-boot-2.png`.
+ - `?mock=android&scheme=light` after ARAHARAH-LIVE + 2000ms tile settle → `shell-live-light.png`.
+ - `?mock=android&scheme=dark` same → `shell-live-dark.png`.
+ - `?mock=tdesktop` (no `grantPermissions`, no `setGeolocation`; the mock's `grantLocation:false` plus no navigator geolocation forces the chain to link 3) → `shell-denied.png`, waiting on the `.card` + `[data-state="denied"]` strip lamp rather than ARAHARAH-LIVE, with the **dedicated 90s race** from step 5 (`DENIED_LEG_TIMEOUT_MS`). **This is the leg that proves the ASK VIA CHAT CTA renders**, which is the entire entry point to GEO-02 on Desktop (issue #77).
+ - `?mock=tdesktop&pin=1` → `shell-chat-pin.png`, waiting for ARAHARAH-LIVE. The `?pin=1` query makes the runner page's `GET /api/location` stub return a pin, exercising the chat-pin link of the chain end-to-end offline — the automated counterpart to the Desktop leg of the device matrix.
+ - `gallery-compare.png`: compose the live-light capture beside `stitch_minimalist_route_navigator/navigate_map_overview/screen.png` using a second page with a two-column grid, or by compositing the two PNGs side by side with a short script. Label each column with its source path.
+7. **GEO-03 runtime probe.** After the live capture, run in-page:
+ ```javascript
+ const probe = await page.evaluate(async () => ({
+ local: localStorage.length,
+ session: sessionStorage.length,
+ cookie: document.cookie,
+ dbs: (await indexedDB.databases()).length,
+ lsKeys: Object.keys(localStorage),
+ }));
+ ```
+ Fail the run if `probe.local !== 0 || probe.session !== 0 || probe.cookie !== "" || probe.dbs !== 0`, with the probe values in the error message. This is GEO-03's "spot-checkable at phase exit" criterion made mechanical.
+8. **Report.** Reuse the Phase-1 report block: log every capture with its byte size, then FAIL on `missing`, `undersized` (`< MIN_BYTES`), or any recorded failure; `process.exit(1)` if any, else `console.log("CAPTURE OK — 7/7 non-blank captures under web/screenshots/")` and `process.exit(0)`.
+
+Never log the bot token or `initData`; the harness reads `BOT_TOKEN` from `bot/.dev.vars` if it needs to sign, and prints only the capture filename and byte size.
+
+
+ cd /home/C-Fu/araharah && npm run capture -w web 2>&1 | tail -25
+
+
+ - `npm run capture -w web` exits 0 and prints `CAPTURE OK — 7/7` with 7 non-blank captures
+ - `ls web/screenshots/ | wc -l` returns >= 7
+ - `for f in web/screenshots/*.png; do test $(stat -c%s "$f") -ge 10240 || echo "UNDERSIZED $f"; done | wc -l` returns 0
+ - `grep -q 'dist-harness' web/scripts/capture.mjs` exits 0 — the harness serves the harness build, NOT the production bundle (the race is unreachable otherwise)
+ - `grep -q 'web/dist-harness/assets' web/scripts/capture.mjs; grep -q "resolve(SCRIPTS, \"..\", \"dist\")" web/scripts/capture.mjs; test $? -ne 0` — the docroot must not be `web/dist`
+ - `grep -q 'installMockWebApp' web/scripts/capture.mjs` exits 0 — the capture asserts the harness bundle really contains the installer before racing
+ - `grep -q 'api/session' web/index.html && grep -q 'api/location' web/index.html && grep -q 'api/location-fallback' web/index.html` exits 0 — all three endpoints are stubbed in the runner page, inside the `__ARAHARAH_HARNESS__` guard
+ - **The build-time guard exists (T-03-21):** `grep -c 'if (__ARAHARAH_HARNESS__) {' web/index.html | head -1` returns 1 AND `node -e "const s=require('fs').readFileSync('web/index.html','utf8'); const g=s.indexOf('if (__ARAHARAH_HARNESS__) {'); const m=s.indexOf('installMockWebApp('); if(g===-1||m===-1) throw new Error('guard or installer missing'); if(m/dev/null | wc -l` returns 0 AND `grep -rl 'installMockWebApp' web/dist-harness/ 2>/dev/null | wc -l` returns >= 1
+ - `grep -q 'import.meta.env.DEV' web/index.html; test $? -ne 0` — must FAIL: the runner page's guard is `__ARAHARAH_HARNESS__` only (a DEV guard would be statically false in BOTH builds and would break the harness); the DEV guard lives only in `main.ts` and is combined with `!window.__ARAHARAH_MOCK__`
+ - `grep -q 'DENIED_LEG_TIMEOUT_MS' web/scripts/capture.mjs` exits 0 AND `grep -q '90000' web/scripts/capture.mjs` exits 0 — the `?mock=tdesktop` denied leg races LINK 3's 60s pre-denial chat-pin poll, not the 30s default (issue: a dead-end capture)
+ - `grep -q 'pin") === "1"' web/index.html` exits 0 (the `?pin=1` chat-pin capture is wired)
+ - `grep -q 'indexedDB' web/scripts/capture.mjs` exits 0 AND `grep -q 'localStorage.length' web/scripts/capture.mjs` exits 0 (the GEO-03 runtime probe exists)
+ - `grep -q 'sessionStorage.setItem\|localStorage.setItem' web/index.html` must FAIL — the harness profile is not persisted
+ - `grep -q '__ARAHARAH_MOCK__' web/index.html` exits 0
+ - `grep -q 'RAMS-BASEMAP-READY' web/scripts/capture.mjs` exits 0 AND `grep -q 'ARAHARAH-LIVE' web/scripts/capture.mjs` exits 0 (both READY signals raced)
+ - `grep -q 'navigate_map_overview' web/scripts/capture.mjs` exits 0 (DSGN-02 side-by-side source)
+ - `grep -q 'MIN_BYTES = 10240' web/scripts/capture.mjs` exits 0 (the Phase-1 blank bar is carried over)
+ - `grep -q 'startsWith(THEME + sep)\|startsWith(DIST + sep)' web/scripts/capture.mjs` exits 0 — the WR-05 docroot guard survives
+ - `grep -q 'declare const __ARAHARAH_HARNESS__' web/src/vite-env.d.ts` exits 0 (the injected constant is declared, so typecheck passes in both modes)
+
+ Seven non-blank captures exist — including the Desktop denied card, the chat-pin relay, and the DSGN-02 side-by-side — and the GEO-03 runtime probe mechanically proves every storage surface is empty after a real boot-to-live cycle.
+
+
+
+ task 3: device-matrix exit gate — Android + iOS + Desktop + plain browser (ROADMAP Success Criterion 5)
+ .planning/phases/03-mini-app-shell-geolocation/03-DEVICE-MATRIX.md
+
+ - `AGENTS.md` (free-first infra mandate; the constraints that bear on staging)
+ - `.planning/phases/03-mini-app-shell-geolocation/03-RESEARCH.md` section "Environment Availability" (Android, iOS, Desktop listed as blocking with NO fallback)
+ - `.planning/phases/03-mini-app-shell-geolocation/03-RESEARCH.md` section "Assumptions Log" (assumption A1, the Android stale-fix, can only be retired on a physical Android phone)
+ - `.planning/phases/03-mini-app-shell-geolocation/03-VALIDATION.md` section "Manual-Only Verifications" (the four manual behaviors)
+
+
+This checkpoint is BLOCKING and cannot be automated. RESEARCH section "Environment Availability"
+lists a real Android phone, a real iOS phone, and a Telegram Desktop install as required dependencies
+with no fallback, and Open Question 3 (whether the executor has physical devices at all) is an open
+question only the user can answer. Assumption A1 (the Android stale-fix workaround) can only be
+retired on a physical Android device.
+
+**Pre-work the executor must complete BEFORE presenting this checkpoint:**
+
+1. Deploy to staging: `npm run deploy` (builds `theme/`, then `web/`, then `wrangler deploy`). Note the deployed URL.
+ Confirm it serves the app and that `POST /api/session` still answers.
+2. **Verify the BotFather-registered Mini App URL matches the deployed origin.** Bot API 10.2
+ (auto-enabled 2026-07-20) rejects Mini App methods called from any other origin, and the failure is
+ silent. If BotFather points elsewhere, `Telegram.WebApp.*` calls fail with no clear error. This is a
+ hard prerequisite — resolve it first or every mobile leg is meaningless.
+3. Write `.planning/phases/03-mini-app-shell-geolocation/03-DEVICE-MATRIX.md` with these six legs,
+ each an unchecked checkbox plus an "Observed / Symptom" blank line:
+ - **Android (real phone, Telegram Android)** — open the Mini App; confirm BOOT-01 then BOOT-02 then
+ LIVE; confirm the position marker renders on the Rams basemap. Then close the app fully, reopen
+ it, and confirm **no second permission prompt** appears. That is GEO-01's "permission persists
+ across sessions": the grant lives in Telegram's per-bot store on the device, outside the WebView —
+ record that observation explicitly, it is the subtlest point in the phase.
+ - **Android stale-fix check (retires A1)** — with the app live, walk several metres, then re-check.
+ If the pin does not move, toggle the phone's Location setting once to force a refresh and record
+ whether the `navigator.geolocation` nudge resolved it. Note explicitly whether the first Android
+ verification needed the Location toggle; that result decides whether A1 stands or is retired.
+ - **iOS (real phone, Telegram iOS)** — same boot to LIVE check; marker renders; permission grant
+ persists across a reopen.
+ - **Desktop Telegram** — geolocation is absent by design (issue #77), so the expected behavior is
+ the **denied card with identical copy** (no client-specific variant, per D-06 and RESEARCH
+ Pitfall 4). Tap ASK VIA CHAT; this POSTs `/api/location-fallback`, and the Worker asks the
+ bot to send the location keyboard into that user's own chat (chat id comes from the session
+ claim — plan 03-01's `handleLocationFallback`). Confirm the keyboard appears in chat; tap it,
+ share a pin; confirm the app picks it up via `GET /api/location` and reaches LIVE. That is GEO-02 end-to-end.
+ - **Plain browser** — open the deployed URL directly in a desktop browser. The app must boot to a
+ deliberate state: **the denied card** (off-Telegram there is no `initData`, so the session cannot be
+ HMAC-validated → 401 → denied). The live branch is NOT reachable here — the denied card short-circuits
+ the chain before any geolocation prompt. The check is that the page renders the denied card over the
+ Rams shell instead of failing to load; a blank page is a FAIL. (If you separately confirm LIVE via a
+ browser geolocation prompt, record it as an extra observation, not as this leg's pass condition.)
+ - **Theme flip, live (PX-02 — VALIDATION.md "Manual-Only Verifications" row 4)** — with the app
+ reached LIVE on a real client, switch the Telegram theme light ↔ dark **mid-session**, without
+ closing or restarting the app. Confirm: (a) the shell chrome re-slots (document background,
+ card field, shell ink all swap); (b) the status strip stays matte-black (Rams-constant); (c) the
+ **map is unchanged** — same style, no flash, no camera jump (D-10 / RESEARCH Pitfall 7: a
+ `setStyle` on `themeChanged` would tear down and rebuild all 110 layers). Record a
+ before/after screenshot in each direction and note which client you flipped. This leg is the
+ ONLY place PX-02's mid-session behavior is verified — the Playwright captures prove only
+ separate cold boots per scheme. Fold it into whichever of the Android/iOS/Desktop legs you can
+ actually perform, and record it as its own checkbox either way; an unrecorded theme flip leaves
+ PX-02's "while the map keeps the Rams cartography constant" half unverified.
+
+**Present to the user with these exact instructions:**
+- Deploy first: `npm run deploy`, and note the deployed URL.
+- Confirm BotFather's Mini App URL is that exact origin.
+- Walk the six legs one at a time on real hardware.
+- Report back which legs passed, which failed, and the exact symptom for each failure.
+
+
+Type "device matrix passed" if all six legs reached LIVE or the documented denied-and-recovered state (the theme-flip leg counts as passed when the chrome re-slotted mid-session and the map was visibly unchanged).
+Otherwise describe which leg failed and the symptom — the executor opens a `/gsd-debug` for it.
+
+
+If the user reports that no physical Android or iOS device is available, the honest outcome is that
+Success Criterion 5 is UNVERIFIED. Do NOT mark the phase complete. Record the blocker in
+`.planning/STATE.md` section "Blockers/Concerns" and recommend either acquiring a device or moving the
+matrix to Phase 7 hardening with an explicit requirement-level note that GEO-01 and GEO-02 remain
+unverified on real hardware. Never close the phase claiming device verification that did not happen.
+
+
+ cd /home/C-Fu/araharah && npm run build 2>&1 | tail -3 && npm test -w web 2>&1 | tail -3 && npm test -w bot 2>&1 | tail -3 && npm run capture -w web 2>&1 | tail -5 && test -f .planning/phases/03-mini-app-shell-geolocation/03-DEVICE-MATRIX.md && grep -c 'PASS\|FAIL\|UNVERIFIED' .planning/phases/03-mini-app-shell-geolocation/03-DEVICE-MATRIX.md
+
+
+ - `npm run capture -w web` exits 0 (the automated half of the gate is green before any device is touched)
+ - `npm test -w web` and `npm test -w bot` both exit 0
+ - `test -f .planning/phases/03-mini-app-shell-geolocation/03-DEVICE-MATRIX.md` exits 0
+ - `grep -c 'Android' .planning/phases/03-mini-app-shell-geolocation/03-DEVICE-MATRIX.md | head -1` returns >= 1 AND the same for `iOS`, `Desktop`, `browser` — all four legs are recorded
+ - `grep -q 'PASS' .planning/phases/03-mini-app-shell-geolocation/03-DEVICE-MATRIX.md` exits 0 (every leg has an explicit verdict recorded)
+ - `grep -q 'A1\|stale-fix\|Location setting' .planning/phases/03-mini-app-shell-geolocation/03-DEVICE-MATRIX.md` exits 0 (the Android stale-fix result is recorded, not left implicit)
+ - `grep -q 'BotFather' .planning/phases/03-mini-app-shell-geolocation/03-DEVICE-MATRIX.md` exits 0 (the Bot API 10.2 origin prerequisite was checked)
+ - `grep -qi 'theme flip\|theme.*light.*dark\|LIGHT ↔ DARK' .planning/phases/03-mini-app-shell-geolocation/03-DEVICE-MATRIX.md` exits 0 (PX-02's mid-session theme flip has its own leg and an explicit verdict — VALIDATION.md lists it as manual-only)
+ - If any leg is UNVERIFIED, `.planning/STATE.md` contains the blocker under "Blockers/Concerns"
+
+ All six legs of the device matrix are verified on real hardware and recorded, or the phase is explicitly held open with the blocker documented — never silently closed on unverified hardware.
+
+
+
+
+
+## Trust Boundaries
+
+| Boundary | Description |
+|----------|-------------|
+| Built bundle → production origin | The artifact that reaches real devices; a CDN slip would leak requests to third parties. |
+| Telegram client → deployed origin | Bot API 10.2 enforces same-origin Mini App calls; a wrong BotFather URL silently breaks WebApp APIs. |
+| Dev harness → production bundle | The Mock-Telegram fake must never ship — it fakes location and the session gate. |
+| Harness build → deployed bundle | Two builds exist; only one is deployable. Conflating them would ship a fake that bypasses the HMAC session gate. |
+
+## STRIDE Threat Register
+
+| Threat ID | Category | Component | Disposition | Mitigation Plan |
+|-----------|----------|-----------|-------------|-----------------|
+| T-03-20 | Information Disclosure | Third-party CDN receiving user navigation requests (or app fingerprinting) | mitigate | D-14 build-artifact gate BA1: every emitted `.js`/`.css` file is scanned for `cdn.tailwindcss.com`, `fonts.googleapis.com`, `fonts.gstatic.com`, `unpkg.com`, `cdn.jsdelivr.net`; any hit fails the suite. Fonts are self-hosted woff2 (BA2), the MapLibre worker is a Vite-emitted hashed asset (BA3), and the style JSON is inlined (BA4). Tiles come only from OpenFreeMap. |
+| T-03-21 | Information Disclosure | Mock-Telegram fake shipping to production | mitigate | **Dual-build isolation, enforced by a build-time constant that folds the branch away.** Two separate guards, both keyed on `__ARAHARAH_HARNESS__` (a Vite `define` that renders as `false` in `vite build` and `true` in `vite build --mode harness`). (1) `main.ts` installs its fallback mock only under `if ((__ARAHARAH_HARNESS__ \|\| import.meta.env.DEV) && !window.__ARAHARAH_MOCK__)` — both halves statically false in production, so the branch is unreferenced and tree-shaken. (2) `web/index.html` — the ONE entry shared by both builds — wraps its ENTIRE profile block (the `window.__ARAHARAH_MOCK__` marker, the `/api/session` + `/api/location` + `/api/location-fallback` fetch stubs, and the `installMockWebApp` call) in `if (__ARAHARAH_HARNESS__) { … }`; `define` substitutes `false`, Rollup dead-code-eliminates the block, and `await import("/src/main.ts")` stays OUTSIDE it so production boots normally. The outDirs are separate (`web/dist/` vs `web/dist-harness/`), and the `[assets]` binding points at `../web/dist` only. **BA5/BA6 scan every emitted `.js` AND every emitted `.html` under `web/dist/` for `installMockWebApp`, `__ARAHARAH_MOCK__`, `/api/session`, and `/api/location-fallback`** — an HTML-inclusive scan is mandatory because the runner page is inline HTML, so a JS-only gate would pass vacuously on exactly the regression this threat describes. `capture.mjs` asserts the inverse (installer PRESENT in `web/dist-harness/`) before racing, so the gate cannot pass by the harness build being empty. |
+| T-03-22 | Spoofing | Bot API 10.2 origin restriction silently disabling WebApp APIs | mitigate | The device-matrix checkpoint's step 2 makes "BotFather URL == deployed origin" a hard prerequisite before any mobile leg. A mismatch is caught there, not shipped. `wireBackButton` and `LocationManager` calls all originate from the app's own document. |
+| T-03-23 | Elevation of Privilege | A compromised asset binding serving arbitrary paths from the Worker origin | mitigate | `assets.directory` is a fixed build output (`../web/dist`); `not_found_handling: "none"` means a miss falls through to the Worker's 404 rather than serving a directory listing; `run_worker_first` guarantees `/api/*` and `/telegram/*` never resolve from the asset tree. |
+| T-03-24 | Information Disclosure | GEO-03 runtime probe leaking probe data into logs | accept | The probe reads counts and key names only (`localStorage.length`, `Object.keys(localStorage)`, `indexedDB.databases().length`) and prints them in the failure message. No coordinate value is read or printed. |
+| T-03-25 | Repudiation | Closing the phase claiming device verification that did not happen | mitigate | The checkpoint's acceptance criteria require an explicit PASS/UNVERIFIED verdict per leg in `03-DEVICE-MATRIX.md`, and the resume-signal block forbids closing on unverified hardware. Missing devices route to a documented Phase-7 deferral instead. |
+| T-03-30 | Repudiation | The capture harness reporting a fake Android capture for a Desktop profile, or racing to LIVE against the wrong build | mitigate | `main.ts` installs a mock only when `window.__ARAHARAH_MOCK__` is absent, so the runner page's `?mock=` profile is the sole authority — an unconditional DEV install would silently clobber `tdesktop`/`dark` with android/light and produce a plausible-looking but wrong artifact. `capture.mjs` aborts with a named diagnostic when the READY race times out (reporting which signals fired) rather than skipping, and asserts the served docroot is `dist-harness` and that the harness bundle really contains the installer. Every capture's profile is stated in the report line. |
+| T-03-31 | Tampering | A future edit serving `web/dist` from `capture.mjs`, reintroducing the unwinnable race | mitigate | The acceptance criteria grep `capture.mjs` for `dist-harness` and require that it does NOT reference `resolve(SCRIPTS, "..", "dist")`. `capture.mjs`'s header comment states the reason in full, so the failure mode is documented at the point of the code that would cause it. |
+
+
+
+- `npm test -w web` green (includes the 6 D-14 build-artifact gates over `web/dist` only, HTML included)
+- `npm test -w bot` green
+- `npm run build` green from a fresh clone (theme → web chain)
+- `npm run capture -w web` exits 0 with 7 non-blank captures off the HARNESS build, including a dark-theme shot, the Desktop denied card, the chat-pin relay, and the DSGN-02 side-by-side
+- `grep -rl 'installMockWebApp\|__ARAHARAH_MOCK__' web/dist/` returns NOTHING (including `web/dist/index.html`) while `grep -rl 'installMockWebApp' web/dist-harness/` succeeds — the two builds are isolated in both directions, and the production scan is not JS-only
+- `03-DEVICE-MATRIX.md` records an explicit verdict for all six legs, or the phase is held open with the blocker in STATE.md
+
+
+
+- D-13: one Worker serves static assets plus `/api/*` plus the webhook, with `run_worker_first` preserving the API routes
+- D-14: the shipped bundle references zero third-party CDNs; style JSON inlined, fonts self-hosted, MapLibre worker bundled
+- D-15: `npm run deploy` from the root is the single deploy command, and it builds theme before web so a fresh clone works
+- T-03-21 holds in both directions: the Mock-Telegram installer and its fetch stubs are provably absent from EVERY file in the deployable bundle (`.js` and `.html`) and provably present in the harness-only build
+- GEO-03: the runtime probe shows localStorage, sessionStorage, cookies, and IndexedDB all empty after a real boot-to-live cycle
+- GEO-01 / GEO-02 / PX-02: verified on Android, iOS, Desktop, plain browser, and one mid-session light↔dark theme flip — or explicitly recorded as unverified with the blocker documented
+
+
+
+After completion, create `.planning/phases/03-mini-app-shell-geolocation/03-04-SUMMARY.md`
+
\ No newline at end of file
diff --git a/.planning/phases/03-mini-app-shell-geolocation/03-CONTEXT.md b/.planning/phases/03-mini-app-shell-geolocation/03-CONTEXT.md
new file mode 100644
index 0000000..503c823
--- /dev/null
+++ b/.planning/phases/03-mini-app-shell-geolocation/03-CONTEXT.md
@@ -0,0 +1,112 @@
+# Phase 3: Mini App Shell + Geolocation - Context
+
+**Gathered:** 2026-10-22
+**Status:** Ready for planning
+
+
+## Phase Boundary
+
+The Telegram Mini App boots inside Telegram, validates its session through Phase 2's /api/session gate, acquires live location reliably across Android/iOS/Desktop/browser with a full fallback chain (Telegram-native → WebView → chat keyboard), and renders the user's position on the Rams basemap under Telegram light + dark themes. Every failure surface is deliberate (never blank); no coordinate persists anywhere. BackButton + safe-area groundwork land here; the observable PX-01/PX-03 behaviors are Phase 6's. Search/routes/guidance are later phases.
+
+
+
+
+## Implementation Decisions
+
+### Mini App stack + repo layout
+- **D-01:** Frontend is TypeScript + Vite, **no UI framework** — MapLibre GL JS v6 bundled, interactivity via small focused modules (the mockups' pattern, productionized). This stack is the foundation Phases 4–6 build on.
+- **D-02:** App source lives in a `web/` npm workspace beside `theme/` and `bot/`; root package.json gains `web:*` delegate scripts. Theme is consumed as a **build-time dependency** (`theme/dist/rams.css` imported into the Vite build, not via CDN) — completing Phase 1's self-hosted/CDN-free decision for the app path.
+- **D-03:** Post-initData validation, the session token is held **memory-only** (module scope), never written to localStorage/sessionStorage/cookies. Revalidation happens on every fresh boot (Telegram revives webviews) and whenever the 900s TTL stalls mid-session. Research may stress-test this invariant across webview reload quirks, but the invariant itself is locked.
+
+### Location acquisition strategy
+- **D-04:** Primary chain: Telegram-native `WebApp.getLocation()` first (Android LocationManager with version-gated paths), fall back to `navigator.geolocation` inside the WebView, then the chat-keyboard "send location" fallback (bot-side; app consumes it). Research details the Android stale-fix and iOS WKWebView quirks — the CHAIN is locked, the per-link mechanics are research's.
+- **D-05:** Cadence is **gated streaming**: one-shot fixes during browsing/search; continuous watch only during guidance (Phase 6 builds the streaming state machine). Map recenters on each fix; the position marker always shows the freshest fix.
+- **D-06:** Denied/no-geolocation presents a deliberate Rams-styled denied state — explains why location matters, offers retry, points to Telegram's attach → Location path as the manual fallback (GEO-02). No silent banners.
+- **D-07:** Coordinates live in **memory only** while the app runs. Zero persistence across every storage API (localStorage, sessionStorage, cookies, IndexedDB, service-worker caches). This zero-persistence assertion is part of the phase's GEO-03 spot-check, stress-tested by research against every storage surface the app touches.
+
+### Shell UX + screens
+- **D-08:** Phase 3's screen surface is map-first and single: full-bleed Rams basemap, position dot, follow/recenter control, minimal status strip reflecting session/location state. Route lines, layer toggles, search furniture belong to later phases — nothing placeholder-shaped.
+- **D-09:** Boot choreography has staged deliberate states: validating session → acquiring location → live — each Rams-styled, none blank (extends the neutral-denied rule from PX-02).
+- **D-10:** Map survives theme flips Rams-constant (Phase 1 D-04 slots pay off); only shell surfaces re-theme via the Telegram theme params. Safe-area handling + BackButton wiring land here as infrastructure.
+
+### Dev/test harness
+- **D-11:** A **Mock-Telegram harness** for the daily loop: a local module injecting a fake `window.Telegram.WebApp` (dev-signed initData, BackButton events, theme params with light/dark switch, sample location streams) plus a runner page booting the real app off-Telegram in a plain browser. Real-device batteries are spent at the device-matrix exit gate, not during daily dev.
+- **D-12:** Local dev topology is **wrangler dev** on 8787 serving web/dist statics + `/api/*` + webhook with local KV simulation; remote staging used sparingly right before real-device verification sessions.
+
+### Worker static-asset wiring
+- **D-13:** Statics served by the **Workers `[assets]` binding** (wrangler config), from `web/dist`'s build output; Worker fetch still owns `/api/*` + webhook first. Completes the one-deployable architecture from the roadmap.
+- **D-14:** Build inlines everything the app needs: CSS from theme, MapLibre, basemap style JSON (from `theme/basemap/liberty-rams.json`), self-hosted fonts copied at build time into `web/dist`. Tiles remain OpenFreeMap's endpoint at runtime; no CDN fallbacks.
+- **D-15:** Deployment stays one command (`npm run deploy` = build web → wrangler deploy with assets). Same ergonomics as Phase 2. Hashed filenames from Vite play naturally with Cloudflare's asset caching.
+
+### OpenCode's Discretion
+- Exact per-link mechanics of the location chain (Telegram API calls, permission bridging) — research's domain
+- Boot-state copy, exact mock initData signing mechanics, recenter-toggle visual details (within the Rams system)
+- `assets` config tuning (binding name, not_found_handling, asset prefixes)
+- How the reserved exit-gate Playwright matrix extends theme/scripts/screenshot.mjs
+
+
+
+
+## Specific Ideas
+
+- The app should feel like the navigate_map_overview mockup distilled: calm, map-dominant, nothing decorative.
+- Allowed fallbacks compose as a chain, not a menu — user should perceive one honest app.
+- Mock harness borrow: dev initData signing already exists conceptually in bot/scripts (HTTP world), and the hand-rolled test runner pattern (theme) prevails.
+
+
+
+
+## Canonical References
+
+**Downstream agents MUST read these before planning or implementing.**
+
+### Phase scope + decisions
+- `.planning/ROADMAP.md` §Phase 3 — goal, 5 success criteria incl. device-matrix exit, research recommendation
+- `.planning/REQUIREMENTS.md` §GEO-01/02/03, PX-02 (this phase's requirements)
+- `.planning/phases/02-bot-skeleton-initdata-security/02-CONTEXT.md` — identity floor /api/session contract details (opaque token, 900s TTL, rotation)
+- `.planning/phases/01-design-tokens-rams-foundation/01-CONTEXT.md` — theme package contract (Rams-constant map, D-04 theme slots)
+
+### Codebase
+- `theme/dist/rams.css`, `theme/src/components.css`, `theme/src/tokens.css` — consumption surface
+- `theme/basemap/liberty-rams.json` — the map style to load
+- `bot/src/webhook.ts`, `bot/src/kv/sessions.ts` — the /api/session contract the web app calls
+- `stitch_minimalist_route_navigator/navigate_map_overview/code.html` — layout reference (read-only)
+- `AGENTS.md` — repo conventions
+
+
+
+
+## Existing Code Insights
+
+### Reusable Assets
+- `theme/dist/rams.css` + `components.css` — the app imports these directly (build-time)
+- `theme/basemap/liberty-rams.json` + `build.mjs` — map style + derivation upstream of a loaded style object
+- `theme/fonts/*` self-hosted woff2 — copied at build time into web/dist
+- `theme/scripts/screenshot.mjs` — Playwright toolchain for exit-gate device-matrix extension
+- Phase 2's `/api/session` — the auth endpoint, deploy scripts + .dev.vars conventions
+
+### Established Patterns
+- npm workspaces with delegate scripts (theme/, bot/)
+- Hand-rolled node test runner (audit.test.mjs shape)
+- TDD discipline; zero-persistence privacy invariant (KV TTL-only)
+
+### Integration Points
+- web/dist → wrangler `[assets]` binding on the existing `araharah-bot` Worker
+- `WebApp.getLocation()` / navigator.geolocation → module that also feeds Phase 5's route snapping and Phase 6's guidance loop
+- BackButton jumps to full state graph in Phase 6
+
+
+
+
+## Deferred Ideas
+
+- Scripted Playwright device-matrix spec (mock-based) — extend theme/scripts toolchain at the exit gate
+- Placeholder-shaped search drawer / route furniture — later phases by design
+- Real-device TestFlight/Play Beta channels — out of scope; device-matrix exit pass suffices for v1
+
+
+
+---
+
+*Phase: 03-mini-app-shell-geolocation*
+*Context gathered: 2026-10-22*
diff --git a/.planning/phases/03-mini-app-shell-geolocation/03-DISCUSSION-LOG.md b/.planning/phases/03-mini-app-shell-geolocation/03-DISCUSSION-LOG.md
new file mode 100644
index 0000000..0d09c22
--- /dev/null
+++ b/.planning/phases/03-mini-app-shell-geolocation/03-DISCUSSION-LOG.md
@@ -0,0 +1,56 @@
+# Phase 3: Mini App Shell + Geolocation - Discussion Log
+
+> **Audit trail only.** Do not use as input to planning, research, or execution agents.
+> Decisions captured in CONTEXT.md — this log preserves the analysis.
+
+**Date:** 2026-10-22
+**Phase:** 03-mini-app-shell-geolocation
+**Mode:** default (interactive)
+
+## Areas Discussed
+
+### Mini App stack + repo layout
+| Question | Options presented | Selection |
+|---|---|---|
+| Frontend stack | TS + Vite no framework / component framework / ES modules | TS + Vite, no framework |
+| App source location | web/ workspace / inside bot/ / web/ + shared/ | web/ workspace |
+| Token holding | Memory-only / sessionStorage / you decide | Memory-only |
+
+### Location acquisition strategy
+| Question | Options presented | Selection |
+|---|---|---|
+| Primary path | Telegram → WebView → chat KB / WebView-only / research decides | Full chain, Telegram first |
+| Cadence | Gated streaming / watch always / you decide | Gated streaming |
+| Denied UX | Deliberate denied state / minimal banner / you decide | Deliberate denied state |
+| Coord persistence | Memory-only / cache last position / you decide | Memory-only, zero persistence |
+
+### Shell UX + screens
+| Question | Options presented | Selection |
+|---|---|---|
+| Screen surface | Map-first single screen / map + placeholder drawer / you decide | Map-first single screen |
+| Boot choreography | Staged boot states / map-first + chip / you decide | Staged boot states |
+
+### Dev/test harness
+| Question | Options presented | Selection |
+|---|---|---|
+| Dev harness | Mock-Telegram / real-only tunnel / mock + Playwright matrix | Mock-Telegram harness |
+| Dev topology | wrangler dev local / remote staging | wrangler dev local |
+
+### Worker static-asset wiring
+| Question | Options presented | Selection |
+|---|---|---|
+| Static serving | Workers [assets] binding / KV/R2 / you decide | Workers [assets] binding |
+| Asset inlining | All-inlined build / basemap kept remote / you decide | All-inlined build |
+| Deploy ergonomics | Single command / split deploys / you decide | Single deploy command |
+
+## Corrections Made
+
+No corrections — user accepted recommended options throughout; chose the leaner mock option over the playwright-matrix option (matrix deferred to exit gate).
+
+## Auto-Resolved
+
+Not applicable.
+
+## External Research
+
+Not applicable (research runs inside planning per roadmap recommendation).
diff --git a/.planning/phases/03-mini-app-shell-geolocation/03-PATTERNS.md b/.planning/phases/03-mini-app-shell-geolocation/03-PATTERNS.md
new file mode 100644
index 0000000..911b2c0
--- /dev/null
+++ b/.planning/phases/03-mini-app-shell-geolocation/03-PATTERNS.md
@@ -0,0 +1,650 @@
+# Phase 3: Mini App Shell + Geolocation - Pattern Map
+
+**Mapped:** 2026-10-22
+**Files analyzed:** 40 (33 new `web/` files, 4 new `bot/` files, 3 modified root/bot config files)
+**Analogs found:** 36 / 40 (4 with no codebase analog — new platform surfaces; RESEARCH.md provides the code)
+
+> Repo state: Phases 1 (theme) + 2 (bot) are complete. There is **no `web/` directory and no frontend app code yet** — every browser-runtime analog below is either a *theme proof page*, a *mockup* (read-only), or a *bot-side* pattern. The strongest analogs are: the Worker route/KV/test patterns from `bot/`, and the MapLibre Rams-basemap proof page `theme/basemap/index.html`.
+
+## File Classification
+
+### `web/` workspace — new files (per RESEARCH.md §Recommended Project Structure + D-01/D-02)
+
+| New/Modified File | Role | Data Flow | Closest Analog | Match Quality |
+|-------------------|------|-----------|----------------|---------------|
+| `web/package.json` | config | — | `bot/package.json` | exact |
+| `web/tsconfig.json` | config | — | `bot/tsconfig.json` | role-match (swap workers-types → DOM) |
+| `web/vite.config.ts` | config | build | *(none — new tool)* | none |
+| `web/index.html` | route/entry | — | `stitch_minimalist_route_navigator/navigate_map_overview/code.html` + `theme/basemap/index.html` | role-match |
+| `web/src/main.ts` | controller (composition root) | event-driven | `theme/basemap/index.html` lines 103-173 (boot script) | role-match |
+| `web/src/shell.css` | component (styles) | — | `theme/basemap/index.html` `