Files

63 KiB

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
phase plan type wave depends_on files_modified autonomous requirements must_haves
04-destination-search-kl-gazetteer 04 execute 3
04-01
04-03
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
false
ONBD-02
ONBD-03
decisions_implemented truths artifacts key_links
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
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
path provides contains
web/src/search/resolve-destination.ts the memory-only id → destination resolution + the two-source payload reader (D-11, mechanism corrected) readDestinationId
path provides
web/src/search/destination-card.ts the destination surface built from the resolved entry (the webview's visible confirmation)
path provides contains
web/src/telegram/homescreen.ts the ONBD-03 addToHomeScreen / checkHomeScreenStatus wiring behind the capability guard (D-12) wireHomeScreen
path provides
web/src/telegram/webapp.ts the narrow TelegramWebAppLike contract gaining startParam, addToHomeScreen?, checkHomeScreenStatus?
path provides
web/src/mock/telegram-mock.ts the harness mock gaining the startParam option, the homeScreenStatus option, and the home-screen stubs
path provides
web/test/destination-preload.test.mjs the ONBD-02 gates: both readers, the unknown-id honest state, the id validation, the memory-only source gate
path provides
web/test/homescreen.test.mjs the ONBD-03 gates: the capability guard, the 'unknown' status handling, the 'added' branch
path provides
web/scripts/capture.mjs the destination-preload capture leg + the ARAHARAH-DEST signal race
from to via pattern
web/src/search/resolve-destination.ts @araharah/search the id resolves against the webview's own build-time copy of the shared gazetteer — the Worker never re-derives it getEntryById
from to via pattern
web/src/search/resolve-destination.ts web/src/main.ts boot reads the destination once and seeds the in-memory destination holder resolveDestination
from to via pattern
web/src/telegram/homescreen.ts web/src/telegram/webapp.ts the capability guard calls the OPTIONAL members on the narrow contract addToHomeScreen
from to via pattern
web/src/main.ts web/src/telegram/webapp.ts boot wires the home screen affordance only when the client supports it wireHomeScreen
from to via pattern
web/scripts/capture.mjs web/index.html the '?dest=<id>' harness profile drives the capture leg through the real reader path dest=
from to via pattern
web/test/build-artifact.test.mjs search/src/data/kl-gazetteer.json the shipped web bundle must carry the gazetteer's id set (the bot/web drift gate) suria-klcc
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.

<execution_context> @$HOME/.config/opencode/get-shit-done/workflows/execute-plan.md @$HOME/.config/opencode/get-shit-done/templates/summary.md </execution_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:

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:

  // 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):

  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:

  /** 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:

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):

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>:

      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):

  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>

task 1: the destination resolver (two carriers, one validation, memory only) 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 - `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) **`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:

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. 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 <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> 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.

task 2: the ONBD-03 home-screen hooks + the boot wiring + the harness profile web/src/telegram/homescreen.ts, web/src/main.ts, web/index.html, web/test/homescreen.test.mjs - `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) **`web/src/telegram/homescreen.ts`** — NEW. Copy `backbutton.ts`'s shape exactly. Implement `wireHomeScreen(wa: TelegramWebAppLike, locale: Locale): void` per the `` 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. 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 <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> 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.

task 3: the build-artifact drift gate + the capture harness + the destination-preload evidence web/test/build-artifact.test.mjs, web/test/no-storage.test.mjs, web/scripts/capture.mjs - `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) **`web/test/build-artifact.test.mjs`** — add the two new cases per the `` 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:

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):
    } 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);
    }
  1. The new leg, copied from the chat-pin leg (line 269):
  /* 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' });
  1. 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. 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 <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> 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.

task 4: the ONBD-02 / ONBD-03 device gate (blocking) 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. 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). **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. 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. 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 <acceptance_criteria> - npm test -w bot, npm test -w web exit 0, and npm run search:corpus exits 0 (threshold met) OR exits 1 with the lever-2/3 outcome recorded in docs/SEARCH-QUALITY.md (the automated half is resolved 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> 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.

<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>
- `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` resolved (exit 0, or the lever-2/3 close recorded per D-14) — 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

<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>
After completion, create `.planning/phases/04-destination-search-kl-gazetteer/04-04-SUMMARY.md`