Files
arah2/.planning/phases/04-destination-search-kl-gazetteer/04-04-PLAN.md
T

630 lines
63 KiB
Markdown

---
phase: 04-destination-search-kl-gazetteer
plan: 04
type: execute
wave: 3
depends_on: [04-01, 04-03]
files_modified:
- web/src/search/resolve-destination.ts
- web/src/search/destination-card.ts
- web/src/telegram/webapp.ts
- web/src/telegram/homescreen.ts
- web/src/mock/telegram-mock.ts
- web/src/i18n/strings.ts
- web/src/main.ts
- web/index.html
- web/package.json
- web/test/destination-preload.test.mjs
- web/test/homescreen.test.mjs
- web/test/no-storage.test.mjs
- web/test/build-artifact.test.mjs
- web/scripts/capture.mjs
autonomous: false
requirements: [ONBD-02, ONBD-03]
must_haves:
decisions_implemented:
- "D-11 + RESEARCH Pitfall 1 (MECHANISM CORRECTED): the webview reads the destination id from `location.search`'s `dest` param FIRST, and from `initData.start_param` SECOND. Both readers apply the SAME validation (`/^[A-Za-z0-9_-]{1,64}$/`). The id is a plain public gazetteer id — NO crypto, NO signature, NO token"
- "D-12 (ONBD-03 scope): minimal PWA hooks only — `addToHomeScreen` / `checkHomeScreenStatus` behind a capability guard, plus a startapp-less boot landing in the search-ready map. NO manifest, NO service worker, NO `beforeinstallprompt`"
- "D-08/D-11: the webview resolves the id against its OWN build-time copy of the shared gazetteer module — the id mapping stays in sync by construction"
- "GEO-03/D-07: the resolved destination lives in MEMORY ONLY — no storage surface, no persistence call"
- "D-12: a startapp-less boot is UNCHANGED — the Phase 3 staged boot still lands in the search-ready map"
truths:
- "Opening the Mini App with '?dest=suria-klcc' resolves the destination to a valid lat/lng from the webview's own gazetteer copy, with no network call"
- "Opening the Mini App with '?dest=suria-klcc' as a start_param (the direct-link path) resolves the SAME destination"
- "An unknown or malformed id produces an honest 'destination unavailable' state — never a blank map and never a fabricated coordinate"
- "The webview and the bot resolve the SAME id set — a build gate proves the shipped web bundle carries the whole gazetteer"
- "The addToHomeScreen affordance is a no-op on a client older than Bot API 8.0 or one without the method"
- "A checkHomeScreenStatus of 'unknown' (always the case on iOS) is treated as 'supported but indeterminate' — never as an error and never as 'not added'"
- "A startapp-less boot lands in the same search-ready map as before — the Phase 3 boot is unchanged"
- "The destination never reaches any storage surface — the runtime probe still finds empty storage after boot"
- "A Playwright capture shows the webview booted WITH a preloaded destination, and the production bundle carries the gazetteer"
artifacts:
- path: "web/src/search/resolve-destination.ts"
provides: "the memory-only id → destination resolution + the two-source payload reader (D-11, mechanism corrected)"
contains: "readDestinationId"
- path: "web/src/search/destination-card.ts"
provides: "the destination surface built from the resolved entry (the webview's visible confirmation)"
- path: "web/src/telegram/homescreen.ts"
provides: "the ONBD-03 addToHomeScreen / checkHomeScreenStatus wiring behind the capability guard (D-12)"
contains: "wireHomeScreen"
- path: "web/src/telegram/webapp.ts"
provides: "the narrow TelegramWebAppLike contract gaining startParam, addToHomeScreen?, checkHomeScreenStatus?"
- path: "web/src/mock/telegram-mock.ts"
provides: "the harness mock gaining the startParam option, the homeScreenStatus option, and the home-screen stubs"
- path: "web/test/destination-preload.test.mjs"
provides: "the ONBD-02 gates: both readers, the unknown-id honest state, the id validation, the memory-only source gate"
- path: "web/test/homescreen.test.mjs"
provides: "the ONBD-03 gates: the capability guard, the 'unknown' status handling, the 'added' branch"
- path: "web/scripts/capture.mjs"
provides: "the destination-preload capture leg + the ARAHARAH-DEST signal race"
key_links:
- from: "web/src/search/resolve-destination.ts"
to: "@araharah/search"
via: "the id resolves against the webview's own build-time copy of the shared gazetteer — the Worker never re-derives it"
pattern: "getEntryById"
- from: "web/src/search/resolve-destination.ts"
to: "web/src/main.ts"
via: "boot reads the destination once and seeds the in-memory destination holder"
pattern: "resolveDestination"
- from: "web/src/telegram/homescreen.ts"
to: "web/src/telegram/webapp.ts"
via: "the capability guard calls the OPTIONAL members on the narrow contract"
pattern: "addToHomeScreen"
- from: "web/src/main.ts"
to: "web/src/telegram/webapp.ts"
via: "boot wires the home screen affordance only when the client supports it"
pattern: "wireHomeScreen"
- from: "web/scripts/capture.mjs"
to: "web/index.html"
via: "the '?dest=<id>' harness profile drives the capture leg through the real reader path"
pattern: "dest="
- from: "web/test/build-artifact.test.mjs"
to: "search/src/data/kl-gazetteer.json"
via: "the shipped web bundle must carry the gazetteer's id set (the bot/web drift gate)"
pattern: "suria-klcc"
---
<objective>
Complete the webview half of the deep-link hand-off: read the destination id from the corrected carrier (`location.search`'s `dest` first, `initData.start_param` second), resolve it against the webview's own build-time copy of the shared gazetteer, render an honest destination surface (never a blank map, never a fabricated coordinate), and land ONBD-03's minimal home-screen hooks — then produce the capture evidence and run the blocking device gate for the deep-link carrier.
Purpose: ONBD-02 is the requirement whose success depends on undocumented Telegram client behaviour, and the research's highest-value finding is that a `web_app` inline button CANNOT populate `start_param` — so the reader must accept BOTH carriers with the same validation, and the phase must not claim the requirement is met until a real device has demonstrated it. ONBD-03 is deliberately minimal (D-12): the Telegram-native `addToHomeScreen()` / `checkHomeScreenStatus()` behind a capability guard, with the iOS `"unknown"` status treated as "possibly added", never as a failure — and no manifest, no service worker, no `beforeinstallprompt` (the browser API does not exist in the Telegram webview).
Output: `web/src/search/resolve-destination.ts`, `web/src/search/destination-card.ts`, `web/src/telegram/homescreen.ts`, the extended `web/src/telegram/webapp.ts` + `web/src/mock/telegram-mock.ts` + `web/src/i18n/strings.ts`, the extended `web/src/main.ts` + `web/index.html`, three test suites, the extended capture harness, and the blocking device gate.
</objective>
<execution_context>
@$HOME/.config/opencode/get-shit-done/workflows/execute-plan.md
@$HOME/.config/opencode/get-shit-done/templates/summary.md
</execution_context>
<context>
@AGENTS.md
@.planning/phases/04-destination-search-kl-gazetteer/04-CONTEXT.md
@.planning/phases/04-destination-search-kl-gazetteer/04-RESEARCH.md
@.planning/phases/04-destination-search-kl-gazetteer/04-PATTERNS.md
@web/src/main.ts
@web/src/telegram/webapp.ts
@web/src/telegram/backbutton.ts
@web/src/session/store.ts
@web/src/mock/telegram-mock.ts
@web/src/i18n/strings.ts
@web/src/ui/cards.ts
@web/src/ui/strip.ts
@web/index.html
@web/scripts/capture.mjs
@web/test/no-storage.test.mjs
@web/test/build-artifact.test.mjs
@search/src/index.ts
@search/src/types.ts
<mechanism_correction_recap>
The bot's OPEN button (plan 04-03) is a `web_app` inline button whose URL carries `?dest=<slug>`. `WebAppInfo` has exactly ONE field — `url` — so `start_param` CANNOT be the carrier for an inline button. The webview therefore reads TWO sources, in this order, with the SAME validation:
1. **PRIMARY — the URL's own query string:** `new URLSearchParams(window.location.search).get("dest")`. This is the inline-button path.
2. **SECONDARY — `initData.start_param`:** the officially documented direct-link path (`https://t.me/<bot>?startapp=<id>`) and the home-screen shortcut's path. Telegram populates `start_param` from a `startattach` attachment-menu link or a `?startapp=` direct link — NOT from an inline `web_app` button.
Both are validated against `/^[A-Za-z0-9_-]{1,64}$/`. The value may carry a `dest_` prefix (the D-11 wording's literal `dest_<id>` form) — the reader strips a leading `dest_` before validation, so BOTH `?dest=suria-klcc` and `?startapp=dest_suria-klcc` resolve to the same entry.
`initData` is the SIGNED string (`Telegram.WebApp.initData`), NOT `initDataUnsafe` — the signed string is what the server validates, and the `start_param` field is present in it verbatim. Parsing it is a two-line split on `&` + `decodeURIComponent`; the repo's `bot/src/security/initdata.ts` `parseFields` shows the pattern (and its warning: `+` stays literal, never `URLSearchParams`).
NO CRYPTO, NO SIGNATURE, NO TOKEN (D-11): the id is a public place id. An unknown id yields an honest "destination unavailable" state — never a fabricated coordinate, never a blank map.
</mechanism_correction_recap>
<resolve_contract>
`web/src/search/resolve-destination.ts` — the memory-only resolver. Copy `web/src/session/store.ts`'s header law verbatim-in-spirit (lines 3-10: "WHAT is stored … WHAT is never stored … no persistence call anywhere in this file").
**Exports:**
```typescript
export type DestinationSource = "url" | "start-param" | "none";
export interface ResolvedDestination {
entry: GazetteerEntry;
source: DestinationSource;
}
/** Read the destination id from BOTH carriers (mechanism corrected per RESEARCH Pitfall 1).
* Primary: the URL's own query string (`?dest=<id>`) — the inline-button path.
* Secondary: initData's `start_param` — the documented direct-link / home-screen path.
* Both are validated against the same charset; a `dest_` prefix is stripped. */
export function readDestinationId(
search: string,
initData: string | null | undefined,
): { id: string | null; source: DestinationSource };
/** Resolve an id against the webview's OWN build-time copy of the shared gazetteer.
* Returns null for an unknown id — the honest null (never a fabricated coordinate). */
export function resolveDestination(id: string | null): GazetteerEntry | null;
/** The memory-only holder: the resolved destination lives in module scope, nowhere else. */
export function setDestination(entry: GazetteerEntry | null): void;
export function getDestination(): GazetteerEntry | null;
```
**The id validation.** `/^[A-Za-z0-9_-]{1,64}$/` AFTER stripping a leading `dest_`. An id failing the regex is treated as absent (never partially trusted).
**The unknown-id path.** `resolveDestination` returns `null` — the caller renders the honest "destination unavailable" surface and the boot continues into the normal staged boot. A blank map is FORBIDDEN (the Phase 3 D-09 anti-blank invariant is binding here too).
**The memory-only law.** The module contains NO storage call. `web/test/no-storage.test.mjs` already walks EVERY `web/src` file, so the new module is covered automatically — but the plan adds an explicit case too.
**`web/src/search/destination-card.ts`** — the visible confirmation. Copy `web/src/ui/cards.ts`'s construction style (the `.card` / `.card-header` / `.card-ref` / `.btn-primary` composed classes, never restyled locally) and `web/src/ui/strip.ts`'s "every string comes from the strings module" law. It renders the destination's NAME and its curated ADDRESS (the address verbatim, never the normalized form — RESEARCH Pitfall 3), and it uses the existing theme classes so no off-token styling appears.
The card is NOT a new screen — it is the Phase 3 card stack's existing surface with a different card. The roadmap's Phase 4 `UI hint: no` means no UI-SPEC round: the destination card composes ONLY classes that already exist in the Phase 3 shell (`web/src/shell.css` + the theme's component sheet).
**The strings.** `web/src/i18n/strings.ts` GAINS:
- `destinationHeadline` — "Destination" / "Destinasi"
- `destinationUnavailableHeadline` — "Destination unavailable" / "Destinasi tidak tersedia"
- `destinationUnavailableBody` — "That place is not in this build. Search again from the bot." / ms equivalent
- `addToHomeScreen` — "ADD TO HOME SCREEN" / "TAMBAH KE SKRIN UTAMA"
- `homeScreenAlreadyAdded` — "Already on your home screen" / "Sudah di skrin utama anda"
The copy-gate suite (`web/test/copy-gate.test.mjs`) asserts the en/ms key-sets match — the new fields are covered automatically by CS1-style key-set identity.
**`web/src/main.ts` — the boot wiring.** The destination read slots adjacent to the locale derivation (stage 12, lines 92-95), because both are "derive from the environment, memory only" steps:
```typescript
// 13 — the destination (ONBD-02). TWO carriers, ONE validation (mechanism
// corrected per RESEARCH Pitfall 1): the URL's own query string first
// (the inline-button path — a web_app button's WebAppInfo carries only
// a `url`), then initData's start_param (the documented direct-link /
// home-screen path). An unknown id yields the honest unavailable state
// and the boot continues into the normal staged boot — never a blank map.
const destPayload = readDestinationId(window.location.search, wa?.initData ?? null);
const destination = resolveDestination(destPayload.id);
setDestination(destination);
```
When `destination !== null`, the destination card is mounted into the existing card stack. When the id was present but unknown, the unavailable card is mounted. When there was no id at all, NOTHING changes — the Phase 3 boot is untouched (D-12's "startapp-less boot landing in the search-ready map"). The boot emits ONE capture signal, UNCONDITIONALLY (outside any map guard, the same shape as the existing `ARAHARAH-LIVE` signal — a WebGL2-absent device still resolves the race):
```typescript
console.log("ARAHARAH-DEST state=" + (destination ? "resolved" : "absent"));
```
The signal is a STATE TOKEN, never the id (D-15 hygiene: a console token carries no place data). Because a token alone cannot prove WHICH destination resolved, the capture leg pairs it with a DOM check on the rendered card's text (task 3's waitMode block asserts `Suria KLCC`).
**`web/src/telegram/webapp.ts` — the contract grows.** The `TelegramWebAppLike` interface gains:
```typescript
/** The documented direct-link start parameter — NOT populated by an inline
* web_app button (its WebAppInfo carries only a `url`). */
startParam?: string;
/** Bot API 8.0+ only. Optional — absent on older clients. */
addToHomeScreen?(): void;
/** Bot API 8.0+ only. On iOS this ALWAYS reports "unknown". */
checkHomeScreenStatus?(cb: (status: HomeScreenStatus) => void): void;
```
plus:
```typescript
export type HomeScreenStatus = "unsupported" | "unknown" | "added" | "missed";
```
Do NOT add `@types/telegram-web-app` (Phase 3 dropped it deliberately — the hand-rolled contract IS the contract; the official type would force each fake to stub ~50 members). The interface's header comment already states this law and must be extended to name the three new members.
**`web/src/mock/telegram-mock.ts` — the mock grows in the SAME change.** The interface's header says "every test fake, the D-11 harness mock, and the production clients satisfy exactly these members" — so the mock MUST gain the three members in this change:
- `MockOptions.startParam?: string | null` — appended to the mock initData as `&start_param=<encoded>` in `buildMockInitData` (the mock's initData is HMAC-unvalidated by design — the runner page stubs `/api/session`).
- `MockOptions.homeScreenStatus?: HomeScreenStatus` — the status the mock's `checkHomeScreenStatus` callback receives.
- The mock's `addToHomeScreen` and `checkHomeScreenStatus` stubs, plus a `__homeScreenAdded` counter the tests can read (the mock already exposes `__events` and `__backButtonClicks` for exactly this purpose).
**`web/src/telegram/homescreen.ts` — the ONBD-03 wiring.** Copy `web/src/telegram/backbutton.ts` (the whole 20-line file is the template: guard on the optional member, a total-function handler, a header that documents the phase-scoped contract):
```typescript
export function wireHomeScreen(wa: TelegramWebAppLike, locale: Locale): void
```
Behaviour:
- The capability guard is BOTH halves: the method may be absent on older clients, AND the platform documents the API as Bot API 8.0+. `if (typeof wa.addToHomeScreen !== "function" || !wa.isVersionAtLeast("8.0")) return;` — a no-op on an unsupported client.
- `checkHomeScreenStatus` — when it reports `"added"`, the affordance is HIDDEN (the icon is already there; do not nag). When it reports `"unknown"` (always on iOS), the affordance is SHOWN with the neutral prompt — "unknown" means "supported but indeterminate", NEVER an error and NEVER "not added". When it reports `"missed"`, the affordance is shown with the prompt. When it reports `"unsupported"`, the affordance is not shown.
- `addToHomeScreen()` is called when the user taps the affordance.
- `wa.onEvent?.("homeScreenAdded", ...)` — Telegram fires this after the icon is added; the affordance hides then.
- The affordance lives in the EXISTING Phase 3 shell — the status strip's right cluster or the card stack. **No new UI furniture is invented** (the roadmap's Phase 4 `UI hint: no`): the affordance is composed from classes that already exist (`web/src/shell.css` + the theme component sheet). If a genuinely new surface were needed, that would require a UI-SPEC round — it does not.
- **NO `beforeinstallprompt`, NO manifest, NO service worker.** `beforeinstallprompt` does not exist inside the Telegram webview and a second icon the Telegram client does not know about is worse than none. `web/test/no-storage.test.mjs`'s GEO3-2 case already bans `serviceWorker.register` / `navigator.serviceWorker` / `workbox` outright.
**`web/index.html` — the `?dest=` harness profile.** The profile block (lines 20-35) reads `?mock=` / `?scheme=` / `?locale=` / `?pin=` / `?sessiondelay=` / `?fixdelay=`. It gains `?dest=<id>`:
```javascript
const dest = q.get("dest") || null;
...
window.__ARAHARAH_MOCK__ = { profile, scheme, locale, dest };
```
The value must reach the app's reader. Because `page.goto` preserves the query string, `window.location.search` ALREADY carries `?dest=<id>` in the webview — the reader path needs no stub. But the mock's `startParam` option needs the value threaded through `installMockWebApp({ ... dest })` for the SECONDARY carrier's harness leg.
The whole block stays inside `if (__ARAHARAH_HARNESS__)` — Vite's `define` dead-code-eliminates it from production, and `BA5`/`BA6` scan the emitted HTML for `installMockWebApp` / `__ARAHARAH_MOCK__` / `/api/session` / `/api/location-fallback`. The `?dest=` value must NOT add a new endpoint or a new inline fetch stub to the production build.
**`web/package.json`** — add `"@araharah/search": "*"` to `dependencies` (the `web/package.json` line 15 `"@araharah/theme": "*"` precedent).
**`web/scripts/capture.mjs` — the ONBD-02 capture leg.** Copy the chat-pin leg (lines 267-269) and the `expected` array (lines 361-369):
```javascript
await captureLeg({ name: 'shell-dest-preload.png', query: 'mock=android&dest=suria-klcc', colorScheme: 'light', grantGeo: true, waitMode: 'dest' });
```
Three bookkeeping edits: the header's output list (lines 22-29), the leg itself, and the `expected` array. The wait mechanism is the console-signal race — add `ARAHARAH-DEST` to the `SIGNALS` list (line 165) and the `seen` map (line 189). `web/src/main.ts` emits the STATE-TOKEN signal shown earlier in this contract (next to the boot-wiring paragraph) — `ARAHARAH-DEST state=resolved|absent`, never the id — UNCONDITIONALLY, outside any map guard, so a WebGL2-absent device still resolves the race.
</resolve_contract>
<build_gate_contract>
`web/test/build-artifact.test.mjs` — the drift gate. RESEARCH §Runtime State Inventory: "the `web/test/build-artifact.test.mjs` gate may need a new assertion (e.g. the gazetteer JSON *must* be in `web/dist` for the webview copy to work)".
**BA7 (new)** — the shipped production bundle carries the gazetteer: read every `web/dist/assets/*.js`, concatenate, and assert that EVERY gazetteer id is present (the ids come from the shared dataset — the suite imports `search/src/data/kl-gazetteer.json`). This is the WEB half of the bot/web id-set parity gate: if the web bundle ships without the dataset, the webview's `resolveDestination` can never resolve an id.
**BA8 (new)** — the production bundle carries NO deep-link crypto: assert the concatenated bundle contains NONE of `createHmac`, `crypto.subtle`, `HMAC(`, `sha256` — the deep-link path is a plain id (D-11). (The Telegram SDK script is loaded from `https://telegram.org/js/telegram-web-app.js` in the HTML — the OFFICIAL script may contain crypto internals. The gate therefore scans the JS ASSETS only, NOT the HTML, and the official script is external (not bundled).)
**The bot/web id-set parity gate** belongs in the BOT suite (the bot test workspace can read both files); plan 04-03's `cari.test.mjs` is the natural home, but the bot suite's `files_modified` in plan 04-03 does not list it — so this plan adds the parity assertion to the WEB suite's BA7 by comparing the web bundle's ids against the committed dataset (the same committed JSON both sides read, so parity holds by construction). The REMAINING risk — a stale web bundle — is what BA7 catches.
**The build-artifact suite's existing behaviour is preserved:** BA1-BA6 must keep passing, and the new cases scan `web/dist` ONLY (never `web/dist-harness`).
</build_gate_contract>
<device_gate_contract>
The blocking human checkpoint at the end of this plan (the phase's exit gate for ONBD-02 and ONBD-03). **A real device is REQUIRED** — RESEARCH §Environment Availability lists it as unavailable in this environment with no fallback, exactly like Phase 3's device matrix. The phase MUST NOT be closed claiming device verification that did not happen.
**What the device must demonstrate:**
1. **ONBD-02, Android:** send `/cari KLCC` in the bot's chat → the venue card appears with the OPEN button → tap OPEN → the Mini App opens WITH the destination preloaded (the destination card is visible, showing Suria KLCC).
2. **ONBD-02, iOS:** the same flow on an iPhone.
3. **ONBD-02, Telegram Desktop:** the same flow (Desktop has no geolocation, so the destination card is what is being observed — the location half is Phase 3's separate matrix).
4. **ONBD-03:** add araharah to the phone's home screen (via the app's affordance or Telegram's own menu) → launch from the home-screen icon → the app opens into the SEARCH-READY state (no destination, no crash, no blank map). On iOS, `checkHomeScreenStatus` will report `"unknown"` — that is expected platform behaviour and MUST NOT be reported as a failure.
5. **The unknown-id honesty:** open the Mini App with a deliberately wrong id (e.g. `?dest=not-a-real-place`) → the app shows the honest "destination unavailable" surface, NOT a blank map and NOT a fabricated pin.
**The record.** Create `.planning/phases/04-destination-search-kl-gazetteer/04-DEVICE-GATE.md` with a row per leg and an explicit `PASS` / `FAIL` / `UNVERIFIED` verdict each. If no device is available, the honest outcome is `UNVERIFIED` — record the blocker in `.planning/STATE.md` under "Blockers/Concerns" and recommend acquiring a device or moving ONBD-02's device verification to Phase 7 with an explicit requirement-level note. NEVER close the phase claiming device verification that did not happen.
</device_gate_contract>
</context>
<tasks>
<task type="auto" tdd="true">
<name>task 1: the destination resolver (two carriers, one validation, memory only)</name>
<files>web/src/search/resolve-destination.ts, web/src/search/destination-card.ts, web/src/telegram/webapp.ts, web/src/mock/telegram-mock.ts, web/src/i18n/strings.ts, web/package.json, web/test/destination-preload.test.mjs</files>
<read_first>
- `web/src/session/store.ts` (the whole file — the memory-only module law the resolver copies)
- `web/src/telegram/webapp.ts` (the whole file — the narrow contract that grows)
- `web/src/mock/telegram-mock.ts` (the whole file — `MockOptions`, `buildMockInitData`, the `__events`/`__backButtonClicks` exposure pattern, and the header law that the mock satisfies the same members as production)
- `web/src/ui/cards.ts` (the card construction style the destination card copies — composed theme classes, strings from the strings module)
- `web/src/i18n/strings.ts` (the interface + en/ms records + `localeFromInitData`)
- `web/test/no-storage.test.mjs` (the comment-strip helper and the banned-token list the resolver must satisfy)
- `web/test/session.test.mjs` (the pure-module + source-gate suite style the new suite copies)
- `search/src/types.ts` (plan 04-01's output — the `GazetteerEntry` shape the resolver returns)
</read_first>
<action>
**`web/package.json`** — add `"@araharah/search": "*"` to `dependencies` (the `"@araharah/theme": "*"` precedent at line 15).
**`web/src/telegram/webapp.ts`** — extend the `TelegramWebAppLike` interface with the three members per the `<resolve_contract>` block, plus the `HomeScreenStatus` type export. Update the header comment to name them and restate the "no `@types/telegram-web-app`" law.
**`web/src/mock/telegram-mock.ts`** — extend `MockOptions` with `startParam?: string | null` and `homeScreenStatus?: HomeScreenStatus`; append `&start_param=<encoded>` to `buildMockInitData` when `startParam` is set; add the `addToHomeScreen` / `checkHomeScreenStatus` stubs and a `__homeScreenAdded` counter; add `startParam` to the fake object. Update the header comment to note the new options.
**`web/src/search/resolve-destination.ts`** — NEW, exactly per the `<resolve_contract>` block. Its header comment states the mechanism correction, the two carriers, the shared validation, the memory-only law, and the honest-null law.
The reader:
```typescript
export function readDestinationId(
search: string,
initData: string | null | undefined,
): { id: string | null; source: DestinationSource } {
const fromUrl = new URLSearchParams(search).get("dest");
const cleaned = fromUrl === null ? null : cleanId(fromUrl);
if (cleaned !== null) return { id: cleaned, source: "url" };
const fromStart = startParamFromInitData(initData);
const cleanedStart = fromStart === null ? null : cleanId(fromStart);
if (cleanedStart !== null) return { id: cleanedStart, source: "start-param" };
return { id: null, source: "none" };
}
```
`cleanId` strips a leading `dest_` and validates against `/^[A-Za-z0-9_-]{1,64}$/`, returning `null` on failure. `startParamFromInitData` parses the SIGNED initData string the way `bot/src/security/initdata.ts`'s `parseFields` does — split on `&`, take the `start_param` field, `decodeURIComponent` it, and treat a decode failure as absent. **Never** use `URLSearchParams` on the initData string (`+` stays literal there) and **never** read `initDataUnsafe`.
`resolveDestination(id)` calls `getEntryById` from `@araharah/search/src/index.ts` (the EXPLICIT subpath-with-extension form — the package has no exports map, so a bare `@araharah/search` specifier resolves to nothing; the `@araharah/theme` subpath precedent) and returns the entry or `null`.
**`web/src/search/destination-card.ts`** — NEW. Build the destination surface from the EXISTING Phase 3 theme classes (`.card`, `.card-header`, `.card-ref`, `.text-headline-md`, `.text-body-sm` — the same classes `web/src/ui/cards.ts` uses) and the strings module. Two builders: `buildDestinationCard(locale, entry)` and `buildDestinationUnavailableCard(locale)`. No new CSS; no off-token styling.
**`web/src/i18n/strings.ts`** — add the five new fields per the `<resolve_contract>` block to the `ShellStrings` interface and to BOTH the `en` and `ms` records. The attribution string stays as-is.
**`web/test/destination-preload.test.mjs`** — NEW. Cases:
- `DP1 the URL carrier resolves` — `readDestinationId("?dest=suria-klcc", "")` → `{ id: "suria-klcc", source: "url" }`.
- `DP2 the start_param carrier resolves` — `readDestinationId("", "user=%7B%7D&auth_date=1&start_param=suria-klcc&hash=h")` → `{ id: "suria-klcc", source: "start-param" }`.
- `DP3 the URL carrier wins over start_param` — with both present, the URL wins (the inline-button path is primary).
- `DP4 the dest_ prefix is stripped` — `?dest=dest_suria-klcc` and `start_param=dest_suria-klcc` both → `suria-klcc` (the D-11 wording's literal form still resolves).
- `DP5 no id yields the honest none` — both absent → `{ id: null, source: "none" }`.
- `DP6 a malformed id is rejected` — `?dest=../../etc/passwd`, `?dest=<script>`, `?dest=` (empty), and a 200-character id all → `{ id: null }`.
- `DP7 the resolver resolves a known id to a valid entry` — `resolveDestination("suria-klcc")` returns an entry with finite `lat`/`lng` and a non-empty `name`.
- `DP8 an unknown id returns null (never a fabricated coordinate)` — `resolveDestination("not-a-real-place")` → `null`.
- `DP9 the memory-only source gate` — read `web/src/search/resolve-destination.ts` (comments stripped) and assert it contains none of `localStorage`, `sessionStorage`, `indexedDB`, `document.cookie`, `caches`.
- `DP10 no crypto in the destination path` — the same file contains none of `createHmac`, `crypto.subtle`, `sha256`, `signature` (D-11).
- `DP11 the id set parity` — every id in the committed gazetteer resolves to an entry (the bot and the webview read the SAME dataset, so parity holds by construction — the assertion proves the webview's copy is complete).
**Acceptance:** `npm run web:typecheck` exits 0; `npm test -w web` green with `PASS DP1`…`PASS DP11`.
</action>
<verify>
<automated>cd /home/C-Fu/araharah && npm install --package-lock-only 2>&1 | tail -3 ; npm run web:typecheck 2>&1 | tail -10 ; npm test -w web 2>&1 | grep -E 'PASS DP|runner' ; grep -c 'startParam' web/src/telegram/webapp.ts web/src/mock/telegram-mock.ts</automated>
</verify>
<acceptance_criteria>
- `npm run web:typecheck` exits 0
- `npm test -w web` exits 0 and the output contains `PASS DP1` through `PASS DP11`
- `grep -q '"@araharah/search": "\*"' web/package.json` exits 0 (the webview imports the shared module)
- `grep -q 'export function readDestinationId' web/src/search/resolve-destination.ts` exits 0 AND `grep -q 'export function resolveDestination' web/src/search/resolve-destination.ts` exits 0
- `grep -q 'URLSearchParams' web/src/search/resolve-destination.ts` exits 0 AND `grep -q 'start_param' web/src/search/resolve-destination.ts` exits 0 (BOTH carriers are read — the mechanism correction)
- `grep -c 'initDataUnsafe' web/src/search/resolve-destination.ts | head -1` returns 0 (only the SIGNED initData is read)
- `grep -q 'A-Za-z0-9_' web/src/search/resolve-destination.ts` exits 0 (the shared id validation)
- `grep -v '^\s*\*\|^\s*//\|^/\*\|^\s*$' web/src/search/resolve-destination.ts | grep -c 'localStorage\|sessionStorage\|indexedDB\|document.cookie\|caches' | head -1` returns 0 (GEO-03 memory-only)
- `grep -v '^\s*\*\|^\s*//\|^/\*\|^\s*$' web/src/search/resolve-destination.ts | grep -c 'createHmac\|crypto\.subtle\|sha256' | head -1` returns 0 (D-11 no crypto)
- `grep -q 'startParam?' web/src/telegram/webapp.ts` exits 0 AND `grep -q 'addToHomeScreen?' web/src/telegram/webapp.ts` exits 0 AND `grep -q 'checkHomeScreenStatus?' web/src/telegram/webapp.ts` exits 0 (the narrow contract grew)
- `grep -q 'homeScreenStatus' web/src/mock/telegram-mock.ts` exits 0 AND `grep -q 'startParam' web/src/mock/telegram-mock.ts` exits 0 (the mock satisfies the same members in the SAME change)
- `grep -q 'destinationUnavailableHeadline' web/src/i18n/strings.ts` exits 0 AND `grep -q 'destinationHeadline' web/src/i18n/strings.ts` exits 0 AND `grep -q 'addToHomeScreen' web/src/i18n/strings.ts` exits 0 (the new copy fields exist in the strings module)
- `node --input-type=module -e "const {strings}=await import('./web/src/i18n/strings.ts'); const en=Object.keys(strings.en).sort(), ms=Object.keys(strings.ms).sort(); if(JSON.stringify(en)!==JSON.stringify(ms)) throw new Error('locale key-sets differ'); console.log('web strings OK')"` exits 0
</acceptance_criteria>
<done>The webview resolves a destination id from BOTH carriers with one validation, against its own build-time copy of the shared gazetteer; an unknown id is an honest null; the resolved destination is memory-only; and the mock satisfies the extended contract in the same change.</done>
</task>
<task type="auto" tdd="true">
<name>task 2: the ONBD-03 home-screen hooks + the boot wiring + the harness profile</name>
<files>web/src/telegram/homescreen.ts, web/src/main.ts, web/index.html, web/test/homescreen.test.mjs</files>
<read_first>
- `web/src/telegram/backbutton.ts` (the whole 20-line file — the optional-API wiring template: guard on the optional member, a total-function handler, the phase-scoped header contract)
- `web/src/main.ts` (the whole file — the numbered boot stages, the card stack, the `ARAHARAH-LIVE` signal at line 289, the install guard at line 228, the locale derivation at lines 92-95)
- `web/src/ui/cards.ts` (the card builders the destination card composes alongside)
- `web/src/ui/strip.ts` (the strip's right cluster — the affordance's host, and the "every string from the strings module" law)
- `web/index.html` (the harness profile block, lines 20-71 — where `?dest=` is threaded)
- `web/test/theme.test.mjs` (the mock-driven suite style the homescreen suite copies)
- `web/test/location-chain.test.mjs` (the injected-fakes style: stub + restore in a finally block)
</read_first>
<action>
**`web/src/telegram/homescreen.ts`** — NEW. Copy `backbutton.ts`'s shape exactly. Implement `wireHomeScreen(wa: TelegramWebAppLike, locale: Locale): void` per the `<resolve_contract>` block's ONBD-03 behaviour:
- The capability guard is BOTH halves: `typeof wa.addToHomeScreen !== "function"` OR `!wa.isVersionAtLeast("8.0")` → return (a no-op on an unsupported client).
- `checkHomeScreenStatus` — `"added"` → the affordance is hidden; `"unknown"` → the affordance is SHOWN with the neutral prompt (iOS always reports "unknown"; it is "supported but indeterminate", NEVER an error and NEVER "not added"); `"missed"` → shown with the prompt; `"unsupported"` → not shown.
- The affordance calls `wa.addToHomeScreen()` on tap.
- `wa.onEvent?.("homeScreenAdded", ...)` hides the affordance after the icon is added.
- **NO `beforeinstallprompt`, NO manifest, NO service worker** — the browser API does not exist inside the Telegram webview and a second icon Telegram does not know about is worse than none.
**`web/src/main.ts` — the boot wiring.** Three edits:
1. After the locale derivation (stage 12, lines 92-95), add the destination read per the `<resolve_contract>` block — stage 13, with its explanatory comment naming the mechanism correction.
2. When `destination !== null`, mount the destination card into the existing card stack (the `stack` element created at lines 87-90). When the id was PRESENT but unknown, mount the "destination unavailable" card. When there was no id, NOTHING changes.
3. Emit the capture signal UNCONDITIONALLY (outside any map guard), the STATE-TOKEN form from task 1's contract: `console.log("ARAHARAH-DEST state=" + (destination ? "resolved" : "absent"));` — never the id (D-15 hygiene).
Also wire the home screen affordance in the Telegram-bindings block (lines 64-68, next to `watchTheme(wa)` / `wireBackButton(wa, overlayPresent)`): `wireHomeScreen(wa, locale)` — but NOTE the locale is derived at stage 12, AFTER that block. Restructure minimally: move the locale derivation ABOVE the Telegram-bindings block, or wire the home screen later (after the locale is known). The locale derivation depends only on `wa`, so moving it up is safe — but `strip.setLocale(locale)` must still run. Keep the change minimal and correct: derive the locale before the bindings block, then call `strip.setLocale(locale)` where it is today.
**`web/index.html` — the `?dest=` profile.** Add `const dest = q.get("dest") || null;` to the harness block, include `dest` in the `window.__ARAHARAH_MOCK__` object, and pass it to `installMockWebApp({ ..., dest })` so the mock's `startParam` can carry it for the secondary carrier's harness leg. The whole block stays inside `if (__ARAHARAH_HARNESS__)`.
**`web/test/homescreen.test.mjs`** — NEW. Cases:
- `HS1 the method being absent is a no-op` — a fake `wa` with no `addToHomeScreen` → `wireHomeScreen` throws nothing and adds no affordance.
- `HS2 an old client is a no-op` — `isVersionAtLeast("8.0")` returns false → no affordance, no call.
- `HS3 an unsupported status hides the affordance` — `checkHomeScreenStatus` reports `"unsupported"` → no affordance.
- `HS4 "added" hides the affordance` — the status `"added"` → no affordance rendered (do not nag).
- `HS5 "unknown" is treated as indeterminate, NOT as an error` — the status `"unknown"` → the affordance IS rendered with the neutral prompt, and NO error path is taken.
- `HS6 "missed" shows the affordance` — the status `"missed"` → the affordance is rendered.
- `HS7 tapping the affordance calls addToHomeScreen` — a click on the affordance → the fake's `addToHomeScreen` was called exactly once.
- `HS8 the homeScreenAdded event hides the affordance` — firing the event → the affordance is gone.
- `HS9 NO browser install APIs` — the homescreen module (comments stripped) contains none of `beforeinstallprompt`, `manifest`, `serviceWorker`, `navigator.install`.
The suite drives the module with injected fakes (the `location-chain.test.mjs` style: build the fake, call, assert, restore in a `finally`).
**Acceptance:** `npm run web:typecheck` exits 0; `npm test -w web` green with `PASS HS1`…`PASS HS9`.
</action>
<verify>
<automated>cd /home/C-Fu/araharah && npm run web:typecheck 2>&1 | tail -10 ; npm test -w web 2>&1 | grep -E 'PASS HS|runner' ; grep -c 'ARAHARAH-DEST' web/src/main.ts ; grep -c 'q.get("dest")' web/index.html ; grep -v '^\s*\*\|^\s*//\|^/\*\|^\s*$' web/src/telegram/homescreen.ts | grep -c 'beforeinstallprompt\|serviceWorker\|manifest' | head -1</automated>
</verify>
<acceptance_criteria>
- `npm run web:typecheck` exits 0
- `npm test -w web` exits 0 and the output contains `PASS HS1` through `PASS HS9`
- `grep -q 'export function wireHomeScreen' web/src/telegram/homescreen.ts` exits 0
- `grep -q 'isVersionAtLeast("8.0")' web/src/telegram/homescreen.ts` exits 0 (the Bot API 8.0+ capability guard)
- `grep -v '^\s*\*\|^\s*//\|^/\*\|^\s*$' web/src/telegram/homescreen.ts | grep -c 'beforeinstallprompt\|serviceWorker\|navigator\.install\|webmanifest' | head -1` returns 0 (D-12: the browser install APIs are forbidden — they do not exist in the Telegram webview)
- `grep -c 'unknown' web/src/telegram/homescreen.ts | head -1` returns >= 1 AND `grep -q 'homeScreenAdded' web/src/telegram/homescreen.ts` exits 0 (the "unknown" branch and the added-event are both handled)
- `grep -c 'ARAHARAH-DEST' web/src/main.ts | head -1` returns 1 (the capture signal)
- `grep -q 'readDestinationId' web/src/main.ts` exits 0 AND `grep -q 'setDestination' web/src/main.ts` exits 0 (the boot reads the destination and seeds the memory-only holder)
- `grep -q 'q.get("dest")' web/index.html` exits 0 (the `?dest=` harness profile)
- `grep -c 'installMockWebApp\|__ARAHARAH_MOCK__' web/dist/index.html 2>/dev/null | head -1` returns 0 (the harness block is still dead-code-eliminated from production — BA5/BA6 still hold)
</acceptance_criteria>
<done>ONBD-03's minimal hooks are live behind a Bot API 8.0 capability guard, with the iOS "unknown" status treated as indeterminate and never as an error; the boot resolves a destination from either carrier and renders an honest surface; and no browser install API appears anywhere.</done>
</task>
<task type="auto" tdd="true">
<name>task 3: the build-artifact drift gate + the capture harness + the destination-preload evidence</name>
<files>web/test/build-artifact.test.mjs, web/test/no-storage.test.mjs, web/scripts/capture.mjs</files>
<read_first>
- `web/test/build-artifact.test.mjs` (the whole file — BA1-BA6, the `walk` helper, the `claimBuildFirst` pattern, the dist-only scope)
- `web/test/no-storage.test.mjs` (the whole file — the banned-token list, the `stripComments` helper, the `listFiles` walker)
- `web/scripts/capture.mjs` (the whole file — the `buildBundles` verification, the `captureLeg` shape, the `SIGNALS` list at line 165, the `seen` map at line 189, the `expected` array at lines 361-369, the header's output list at lines 22-29)
- `web/src/main.ts` (the `ARAHARAH-DEST` signal task 2 added — the capture's wait mechanism)
- `web/index.html` (the `?dest=` profile task 2 added)
</read_first>
<action>
**`web/test/build-artifact.test.mjs`** — add the two new cases per the `<build_gate_contract>` block:
- **BA7** — the shipped production bundle carries the gazetteer: read every `web/dist/assets/*.js`, concatenate, and assert EVERY id from `search/src/data/kl-gazetteer.json` is present in the bundle. (The suite imports the dataset with `import gazetteer from "../../search/src/data/kl-gazetteer.json" with { type: "json" };` — the same import assertion the Node 24 corpus runner uses.)
- **BA8** — the shipped production bundle carries NO deep-link crypto: the concatenated JS assets contain none of `createHmac`, `crypto.subtle`, `sha256`, `HMAC(`. (Scan the JS ASSETS only, not the HTML — the official Telegram SDK script is loaded externally from `telegram.org`, not bundled, and it may contain crypto internals of its own.)
Both cases keep the existing `claimBuildFirst()` behaviour when `web/dist` does not exist.
**`web/test/no-storage.test.mjs`** — the existing GEO3 gate already walks EVERY `web/src` file, so `web/src/search/*` is covered automatically. Add ONE explicit case pinning the new tree's memory-only law:
```javascript
test("GEO3 web/src/search/ holds no persistence surface and no deep-link crypto", () => {
for (const file of listFiles(path.join(SRC, "search"), [".ts"])) {
const code = codeText(file);
for (const banned of BANNED) {
assert.ok(!code.includes(banned), `${path.relative(process.cwd(), file)} references ${banned} — GEO-03 violated`);
}
for (const token of ["createHmac", "crypto.subtle", "sha256"]) {
assert.ok(!code.includes(token), `${path.relative(process.cwd(), file)} references ${token} — D-11 forbids crypto in the deep-link path`);
}
}
});
```
**`web/scripts/capture.mjs`** — three bookkeeping edits plus one new leg and one new wait mode, per the `<resolve_contract>` block:
1. The header's output list (lines 22-29) gains `shell-dest-preload.png the Mini App booted WITH a preloaded destination (?dest=<id>)`.
2. The `SIGNALS` array (line 165) gains `'ARAHARAH-DEST'`; the `seen` map (line 189) gains `'ARAHARAH-DEST': false`; the `signalDiagnostics` string names it.
3. The new wait mode, added to `captureLeg`'s wait chain (after the `live` branch at line 218 and before the `denied` branch):
```javascript
} else if (waitMode === 'dest') {
const signal = await Promise.race([
page.waitForEvent('console', {
predicate: (m) => m.text().startsWith('ARAHARAH-DEST'),
timeout: SIGNAL_TIMEOUT_MS,
}).then(() => 'signal'),
new Promise((r) => setTimeout(() => r('timeout'), SIGNAL_TIMEOUT_MS)),
]);
if (signal === 'timeout') {
throw new Error(`ARAHARAH-DEST race timed out after ${SIGNAL_TIMEOUT_MS}ms — ${signalDiagnostics()}`);
}
/* The destination must actually be the RESOLVED one — a signal that fired
* with state=absent means the payload never reached the resolver (T-04-42).
* The signal is a STATE TOKEN (no id — D-15 hygiene), so the leg then
* proves WHICH destination resolved via the rendered card's text. */
const destLine = consoleLines.filter((l) => l.startsWith('ARAHARAH-DEST')).pop() ?? '';
if (!destLine.includes('state=resolved')) {
throw new Error(`destination did not resolve: "${destLine}" — expected state=resolved`);
}
const bodyText = await page.textContent('body');
if (!bodyText || !bodyText.includes('Suria KLCC')) {
throw new Error('the resolved destination card does not show "Suria KLCC" — the wrong entry or no entry rendered');
}
await page.waitForTimeout(500);
}
```
4. The new leg, copied from the chat-pin leg (line 269):
```javascript
/* 8 — ONBD-02: the destination-preload boot. ?dest=<id> carries the plain
* gazetteer id in the URL's own query string (the inline-button carrier —
* RESEARCH Pitfall 1). The ARAHARAH-DEST signal resolves the race. */
await captureLeg({ name: 'shell-dest-preload.png', query: 'mock=android&dest=suria-klcc', colorScheme: 'light', grantGeo: true, waitMode: 'dest' });
```
5. The `expected` array (lines 361-369) gains `'shell-dest-preload.png'`, and the final success line becomes `CAPTURE OK — 8/8 non-blank captures under web/screenshots/`.
**Acceptance:** `npm run web:typecheck` exits 0; `npm test -w web` green (BA1-BA8 + DP + HS + the existing suites); `npm run capture -w web` exits 0 with 8 non-blank captures including the destination-preload one.
</action>
<verify>
<automated>cd /home/C-Fu/araharah && npm run build -w theme 2>&1 | tail -2 && npm run build -w web 2>&1 | tail -3 ; npm test -w web 2>&1 | grep -E 'PASS BA|runner' ; npm run capture -w web 2>&1 | tail -8 ; ls -la web/screenshots/shell-dest-preload.png 2>/dev/null</automated>
</verify>
<acceptance_criteria>
- `npm run build -w theme && npm run build -w web` exits 0
- `npm test -w web` exits 0 and the output contains `PASS BA1` through `PASS BA8`
- `grep -q 'BA7' web/test/build-artifact.test.mjs` exits 0 AND `grep -q 'BA8' web/test/build-artifact.test.mjs` exits 0 (the drift gate and the no-crypto gate exist)
- `grep -rl 'suria-klcc' web/dist/assets/*.js | wc -l` returns >= 1 (the gazetteer IS in the shipped web bundle — the webview's id lookup can resolve)
- `grep -v '^\s*\*\|^\s*//\|^/\*\|^\s*$' web/dist/assets/*.js | grep -c 'createHmac\|crypto\.subtle\|sha256' | head -1` returns 0 (no crypto in the shipped bundle — D-11)
- `npm run capture -w web` exits 0 and the report line reads `CAPTURE OK — 8/8 non-blank captures under web/screenshots/`
- `test -f web/screenshots/shell-dest-preload.png` exits 0 AND `find web/screenshots/shell-dest-preload.png -size +10240c | wc -l` returns 1 (the destination-preload capture exists and is not blank)
- `grep -q 'shell-dest-preload.png' web/scripts/capture.mjs` exits 0 AND `grep -q "waitMode: 'dest'" web/scripts/capture.mjs` exits 0 AND `grep -q 'ARAHARAH-DEST' web/scripts/capture.mjs` exits 0 (the leg, its wait mode, and its signal are wired)
- `grep -q 'web/src/search' web/test/no-storage.test.mjs` exits 0 (the new tree's memory-only law is pinned explicitly)
</acceptance_criteria>
<done>The shipped web bundle provably carries the whole gazetteer and no crypto; the capture harness produces a destination-preload screenshot that is verified to have resolved the specific id; and the existing build-artifact and no-storage gates keep holding over the new tree.</done>
</task>
<task type="checkpoint:human-verify" gate="blocking">
<name>task 4: the ONBD-02 / ONBD-03 device gate (blocking)</name>
<action>Everything automatable has been automated in tasks 1-3 (the web suite, the bot suite, the corpus gate, the build, and the destination-preload capture). This task is the blocking human checkpoint that verifies what CANNOT be automated: the deep-link carrier's behaviour on real Telegram clients and the home-screen launch on a real device. Deploy first (`npm run deploy` from the repo root), confirm the @BotFather Mini App URL and `MINI_APP_URL` equal the deployed origin, then walk the six verification legs below with the user and record an explicit PASS / FAIL / UNVERIFIED verdict per leg in `.planning/phases/04-destination-search-kl-gazetteer/04-DEVICE-GATE.md` (create the file if it does not exist). An UNVERIFIED leg is recorded honestly and the blocker goes into `.planning/STATE.md` under "Blockers/Concerns" — the phase is held open on it, never silently closed.</action>
<what-built>The bot's `/cari` command with its native venue card and inline OPEN button (plan 04-03), and the webview's destination resolution from both carriers plus the minimal home-screen hooks (tasks 1-2), with the automated evidence green (the web suite, the bot suite, the corpus, and the destination-preload capture).</what-built>
<how-to-verify>
**Deploy first.** `npm run deploy` from the repo root (it builds theme → web → deploys the Worker with the static-assets binding). Note the deployed URL. Confirm @BotFather's Mini App URL is EXACTLY that origin — the Bot API 10.2 origin restriction means a mismatch silently breaks every WebApp call. Also confirm `MINI_APP_URL` in `bot/wrangler.jsonc` equals that same origin (it is what the OPEN button's URL is built from).
**Leg 1 — ONBD-02, Android (the carrier that matters).** In Telegram on an Android phone: send `/cari KLCC` to the bot → a native venue card for Suria KLCC appears, with an "OPEN IN ARAHARAH" button and 2-4 alternates → tap OPEN → the Mini App opens AND the destination card shows Suria KLCC with its address. Expected: the destination is PRELOADED. A blank map, an empty card stack, or "Destination unavailable" is a FAIL for this leg.
**Leg 2 — ONBD-02, iOS.** The same flow on an iPhone. Same expectation: the destination is preloaded.
**Leg 3 — ONBD-02, Telegram Desktop.** The same flow on Desktop. Desktop has no geolocation (Phase 3's issue #77), so observe the DESTINATION card specifically; the location half is Phase 3's separate matrix and is not this leg.
**Leg 4 — ONBD-03, home screen.** With the app open on the phone, use the app's "ADD TO HOME SCREEN" affordance (or Telegram's own "Add to Home Screen" menu item) → the icon appears on the home screen → close the app → tap the icon → the app opens into the SEARCH-READY state: the map renders, the status strip shows its normal boot state, and there is NO destination and NO crash. On iOS, `checkHomeScreenStatus` reports "unknown" — that is the platform's documented limitation and MUST NOT be treated as a failure.
**Leg 5 — the honest unknown-id state.** Open the Mini App with a deliberately wrong id (append `?dest=not-a-real-place` to the deployed URL in a browser, or send a crafted link). Expected: the app shows the "Destination unavailable" surface — NOT a blank map, NOT a fabricated pin, NOT a crash.
**Leg 6 — the honest refusal.** Send `/cari Kuching` in the chat. Expected: a plain-text reply naming the v1 area, with NO venue card, NO inline keyboard, and NO pin.
Record every leg's result in `.planning/phases/04-destination-search-kl-gazetteer/04-DEVICE-GATE.md` with an explicit `PASS` / `FAIL` / `UNVERIFIED` verdict, and note the client version and OS for each leg.
</how-to-verify>
<resume-signal>
Type "device gate passed" if legs 1-3 show the destination preloaded and leg 4 opens into the search-ready state (leg 4 passes on iOS even when the status is "unknown").
Otherwise describe which leg failed and the exact symptom. If no physical device is available, say so — the honest outcome is UNVERIFIED, and the executor records the blocker in `.planning/STATE.md` under "Blockers/Concerns" and recommends acquiring a device or deferring ONBD-02's device verification to Phase 7 with an explicit requirement-level note. NEVER close the phase claiming device verification that did not happen.
</resume-signal>
<verify>
<automated>cd /home/C-Fu/araharah && npm test -w bot 2>&1 | tail -3 && npm test -w web 2>&1 | tail -3 && npm run search:corpus 2>&1 | tail -5 && test -f .planning/phases/04-destination-search-kl-gazetteer/04-DEVICE-GATE.md && grep -c 'PASS\|FAIL\|UNVERIFIED' .planning/phases/04-destination-search-kl-gazetteer/04-DEVICE-GATE.md</automated>
</verify>
<acceptance_criteria>
- `npm test -w bot`, `npm test -w web`, and `npm run search:corpus` all exit 0 (the automated half is green BEFORE any device is touched)
- `test -f .planning/phases/04-destination-search-kl-gazetteer/04-DEVICE-GATE.md` exits 0
- `grep -c 'PASS\|FAIL\|UNVERIFIED' .planning/phases/04-destination-search-kl-gazetteer/04-DEVICE-GATE.md | head -1` returns >= 6 (all six legs carry an explicit verdict)
- `grep -q 'Android' .planning/phases/04-destination-search-kl-gazetteer/04-DEVICE-GATE.md` exits 0 AND `grep -q 'iOS' .planning/phases/04-destination-search-kl-gazetteer/04-DEVICE-GATE.md` exits 0 AND `grep -q 'Desktop' .planning/phases/04-destination-search-kl-gazetteer/04-DEVICE-GATE.md` exits 0 AND `grep -q 'home screen' .planning/phases/04-destination-search-kl-gazetteer/04-DEVICE-GATE.md` exits 0 (the legs are recorded, not implied)
- `grep -q 'MINI_APP_URL\|BotFather' .planning/phases/04-destination-search-kl-gazetteer/04-DEVICE-GATE.md` exits 0 (the origin prerequisite was checked — a wrong origin is the first thing that would break every leg)
- If any leg is UNVERIFIED, `.planning/STATE.md` contains the blocker under "Blockers/Concerns"
</acceptance_criteria>
<done>The ONBD-02 deep-link carrier and the ONBD-03 home-screen launch are verified on real hardware and recorded, OR the phase is explicitly held open with the blocker documented — never silently closed on unverified hardware.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| Telegram client → Mini App webview | The `?dest=` query param and the `start_param` field are both attacker-influenced strings. The webview MUST NOT trust either. |
| Webview → the shared gazetteer | The id is resolved against a local, committed dataset — the lookup is a pure function, and an unknown id must produce an honest state, never a guess. |
| Webview → storage | The resolved destination is user context; GEO-03 forbids it from reaching any persistence surface. |
| Telegram client capability → the home-screen affordance | `addToHomeScreen` / `checkHomeScreenStatus` exist only on Bot API 8.0+ clients, and iOS always reports `"unknown"`. The affordance must degrade silently. |
| Harness build → production bundle | The `?dest=` harness profile must stay inside the dead-code-eliminated block — a leak would ship the mock into production. |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-04-32 | Tampering | A hostile `dest` query param — a path traversal, an XSS payload, or a 10 KB string — reaching a lookup or a render | mitigate | The id is validated against `/^[A-Za-z0-9_-]{1,64}$/` BEFORE any lookup (DP6 asserts a traversal-shaped, an XSS-shaped, an empty, and a 200-char id all resolve to `null`). The lookup is an exact key match against a committed map (`getEntryById`), never a substring or a path join. The id is never interpolated into HTML — the destination card is built with `document.createElement` + `textContent` (the `web/src/ui/cards.ts` pattern), never `innerHTML`. |
| T-04-33 | Tampering | A malicious `start_param` being treated as an identity or a capability | mitigate | `initData` is the SIGNED string, not `initDataUnsafe`; the `start_param` field inside it is treated as an UNTRUSTED payload (Telegram's own docs call `initDataUnsafe` untrusted, and the signed string's individual fields are still just data). The value is used ONLY to look up a public place id in the local gazetteer. It is never an identity, never a URL, never a path. An unknown id yields the honest unavailable state. |
| T-04-34 | Spoofing | The webview resolving an id the bot never sent — a user crafting `?dest=some-other-place` | mitigate | Accepted by design (D-11: "the payload's only threat is a user pranking themselves"). The consequence is bounded: the user gets a DIFFERENT public destination preloaded in their OWN session. There is no privilege attached to an id — it names a public place, grants no capability, and leaks nothing about another user. The honesty requirement is the real mitigation: an id that does not resolve produces "destination unavailable", never a fabricated coordinate. |
| T-04-35 | Information Disclosure | The resolved destination (or the raw id) reaching a persistence surface (GEO-03/D-07) | mitigate | The resolver holds the destination in module scope only — the `web/src/session/store.ts` pattern. `web/test/no-storage.test.mjs`'s GEO3 gate walks EVERY `web/src` file (including the new `web/src/search/` tree) and bans `localStorage`/`sessionStorage`/`indexedDB`/`document.cookie`/`caches`; a new explicit case pins the new tree and the no-crypto law. The capture harness's GEO-03 runtime probe still asserts empty storage after boot-to-live. |
| T-04-36 | Denial of Service | A client without `addToHomeScreen` (pre-Bot-API-8.0) throwing on the call, breaking the boot | mitigate | The guard is BOTH halves — the method's presence AND `isVersionAtLeast("8.0")` — and it returns BEFORE any call. `HS1`/`HS2` assert the no-op on a missing method and on an old client. The boot never awaits the affordance; a rejected `addToHomeScreen()` is caught and ignored (the affordance is best-effort). |
| T-04-37 | Repudiation | The home-screen affordance claiming success on iOS, where the platform ALWAYS reports `"unknown"` | mitigate | The status string is NEVER treated as a failure and never as "not added". `"unknown"` means "supported but indeterminate" and the affordance shows the neutral prompt. `"added"` is the ONLY status that hides the affordance. `HS4`/`HS5` assert both branches. A failed `addToHomeScreen()` is not reported as an error either — the affordance stays available for a retry. |
| T-04-38 | Spoofing | A second, Telegram-unaware app icon being installed by a browser install API | mitigate | The implementation uses ONLY `Telegram.WebApp.addToHomeScreen()`. `HS9` asserts the module contains no `beforeinstallprompt`, no `manifest`, no `serviceWorker`, no `navigator.install` — the browser API does not exist in the Telegram webview, and a second icon Telegram does not know about is worse than none. `web/test/no-storage.test.mjs`'s GEO3-2 case independently bans `serviceWorker.register` / `navigator.serviceWorker` / `workbox` across all of `web/src`. |
| T-04-39 | Tampering | The web bundle shipping a STALE gazetteer that cannot resolve an id the bot just sent (RESEARCH Pitfall 9) | mitigate | The ids are never renamed, only added. BA7 asserts the shipped bundle contains EVERY id from the committed dataset. The bot and the webview read the SAME committed JSON in the same `npm run build` chain, so the id sets are identical by construction; the residual risk (a stale deployed bundle) is exactly what BA7 catches at build time. |
| T-04-40 | Information Disclosure | The shipped web bundle carrying a crypto/HMAC implementation in the deep-link path — a signed-token scheme introduced where D-11 forbids one | mitigate | DP10 asserts the resolver source contains no `createHmac`/`crypto.subtle`/`sha256`; BA8 asserts the SHIPPED BUNDLE contains none either (JS assets only — the official external Telegram SDK script is not bundled). The deep link is a plain public id; a signed-token scheme would be security theatre against a self-inflicted threat (RESEARCH §Security V6: "no new crypto — by design"). |
| T-04-41 | Tampering | The harness profile block (`?dest=`) leaking into the production bundle, shipping the Mock-Telegram fake (which fakes location AND the session gate) | mitigate | The whole profile block stays inside `if (__ARAHARAH_HARNESS__)`, which Vite's `define` renders as `false` in the production build — dead-code-eliminated. The existing BA5/BA6 cases scan EVERY emitted `.js` AND `.html` under `web/dist` for `installMockWebApp`, `__ARAHARAH_MOCK__`, `/api/session`, and `/api/location-fallback`; the `?dest=` addition introduces NO new endpoint and NO new inline fetch stub, so the gate's coverage is unchanged. The capture harness asserts the inverse (installer PRESENT in `web/dist-harness`), so the gate cannot pass by the harness build being empty. |
| T-04-42 | Denial of Service | The capture leg racing on the wrong signal — resolving on `ARAHARAH-LIVE` (the LOCATION signal) and photographing a boot that never resolved the destination | mitigate | The new `waitMode: 'dest'` waits on `ARAHARAH-DEST` specifically, asserts the line CONTAINS `state=resolved` (a `state=absent` firing fails the leg with a named diagnostic), and then asserts the rendered card's text carries `Suria KLCC` — so a leg that resolved the WRONG entry also fails. The signal itself is a STATE TOKEN carrying no place data (D-15 hygiene). This is the anti-fake pattern the Phase 3 harness already uses for the theme-slot check. |
</threat_model>
<verification>
- `npm test -w web` green — including the 11 destination-preload cases, the 9 home-screen cases, BA1-BA8, and the extended no-storage gate
- `npm test -w bot` green (unaffected by this plan, but the deep-link contract's other half)
- `npm run search:corpus` green (the shared module both sides depend on)
- `npm run build -w theme && npm run build -w web` green from a fresh clone
- `npm run capture -w web` exits 0 with 8 non-blank captures, including `shell-dest-preload.png` whose leg verified `state=resolved` AND the rendered card text `Suria KLCC` specifically
- `grep -rl 'suria-klcc' web/dist/assets/*.js` succeeds (the gazetteer ships in the bundle) AND the same grep over `web/dist/assets/*.js` for `createHmac|crypto.subtle|sha256` returns NOTHING (no crypto in the shipped bundle)
- `04-DEVICE-GATE.md` records an explicit verdict for all six legs, or the phase is held open with the blocker in STATE.md
</verification>
<success_criteria>
- ONBD-02: tapping OPEN on the venue card launches the Mini App with the destination PRELOADED — the id travels as `?dest=<slug>` in the `web_app` button's URL (the mechanism correction), with `initData.start_param` as the secondary carrier, and NO crypto anywhere in the path
- ONBD-02 (honesty): an unknown or malformed id yields the "destination unavailable" surface — never a blank map, never a fabricated coordinate
- ONBD-03: the app can be added to the phone's home screen and launching from there lands in the SEARCH-READY state — `addToHomeScreen` behind a Bot API 8.0 capability guard, with the iOS `"unknown"` status treated as indeterminate
- D-12: the startapp-less boot is UNCHANGED — the Phase 3 staged boot still lands in the search-ready map
- GEO-03: the resolved destination is memory-only; the storage surfaces stay empty (source gate + runtime probe)
- The shipped web bundle carries the whole gazetteer (the bot/web id-set parity gate) and no crypto
- The device gate is executed on real Android, iOS, and Desktop, with the home-screen launch verified — or the phase is explicitly held open with the blocker documented
</success_criteria>
<output>
After completion, create `.planning/phases/04-destination-search-kl-gazetteer/04-04-SUMMARY.md`
</output>