search copy in en+ms (usage / honest refusal / over-cap / OPEN label / OSM attribution) + /cari helpMenu lines
CR1-CR15 payload-shape gate suite (fake-transport + source-introspection) and the four amended CS gates
plan 04-04 (webview reads ?dest= from location.search; device gate validates the same origin)
phase 5 (origin/destination resolvers plug into the same SearchSource; /pergi_ke reserved there)
added
patterns
fake-transport payload capture: createBot(env, { baseFetch }) + synthetic Telegram updates (bot_command entity required) — asserts run on recorded request bodies, never the live API
structural gates count RAW source: comment prose must not carry gate-banned tokens (editMessageText / keyboard / pergi_ke literals)
carrier-swap seam: miniAppUrlFor(origin, id) is the one place the deep link is built
created
modified
bot/src/bot/commands/cari.ts
bot/src/bot/handlers/search-alternate.ts
bot/test/cari.test.mjs
bot/src/i18n/strings.ts
bot/src/bot/bot.ts
bot/src/bot/handlers/location-fallback.ts
bot/src/config.ts
bot/wrangler.jsonc
bot/.dev.vars.example
bot/package.json
package-lock.json
bot/scripts/register-commands.mjs
bot/test/commands.test.mjs
docs/CONFIGURATION.md
Deep-link carrier corrected per RESEARCH Pitfall 1: WebAppInfo has exactly one field (url), so the OPEN button carries ?dest=<plain slug id> read from location.search; initData.start_param stays the secondary direct-link/home-screen path; no crypto (CR3 enforces)
MINI_APP_URL set to the REAL deployed origin https://araharah-bot.araharah-bot.workers.dev (matches the deployed WEBHOOK_URL origin; public var, not a secret) — must equal the @BotFather Mini App URL, verified at 04-04's device gate
Blank MINI_APP_URL omits the OPEN button (never guesses an origin); the card + alternates still send (T-04-30)
CS4 carve documented as the SECOND deliberate exception (inline keyboard in commands/cari.ts output); reply keyboards remain banned in commands/
CS5b future-stub ban narrowed to /pergi_ke only — /cari is live, the Phase-5 navigation command stays unregistered (D-01)
One-liner:/cari (+ /search) turns a query into ONE native sendVenue card — canonical name,
curated address, inline OPEN IN ARAHARAH web_app button carrying ?dest=<plain slug id> from the
configured MINI_APP_URL, plus 2–4 alternates as alt:<id> callback buttons that swap the card in
one tap — with honest out-of-area refusal, a 128-char pre-search cap, category-only logging, and a
15-case payload gate suite proving the venue shape, the no-crypto deep link, and the always-answer rule.
Performance
Duration: ~13 min
Started: 2026-10-23T17:10:48Z
Completed: 2026-10-23T17:24:00Z
Tasks: 3/3
Files modified: 14 (3 created, 11 modified)
Accomplishments
The chat-side search surface is live end-to-end through the real createBot factory: search →
ONE venue message → OPEN button deep link → alternates swap, all gated by tests that capture the
exact request bodies Telegram would receive.
The deep-link mechanism is the research-corrected carrier: web_app button URL carries the plain
gazetteer id (?dest=<slug>), because WebAppInfo has exactly one field (url) — start_param
cannot ride an inline button. D-11's locked intent (plain id, no crypto, one tap) is delivered
unchanged; CR3 structurally bans every crypto token from the deep-link path.
Privacy law is enforced mechanically: every console.log in cari.ts must be a category-only
string literal ([search] rejected: <category>), the success path logs nothing, refusal copy
never echoes the query, and callback_data is a public place id only (≤ 64 bytes).
The four commands.test.mjs landmines were defused in the same plan that landed /cari (CS4
carve, CS5 menu, CS5b narrowed stub ban, CS6 alias set) — the suite went RED on cue after task 2
and is green again at close.
Task Commits
task 1: the search copy + the /cari command + the venue card + the OPEN deep link - a73a388 (feat)
task 2: the alternates callback handler + the bot factory wiring - 13e5073 (feat)
task 3: the four commands.test.mjs landmines + the new cari.test.mjs gate suite - 5dcef0e (test)
Files Created/Modified
bot/src/bot/commands/cari.ts (NEW) — the /cari + /search handler: query cap before any search,
SearchSource call, honest refusals, one sendVenueCard call; exports MAX_QUERY_CHARS,
miniAppUrlFor, the pure text builders, registerCari
bot/src/bot/handlers/search-alternate.ts (NEW) — the ONE sendVenueCard builder, async
alternatesFor, the scoped alt: callback matcher (answer → validate → byId → swap), the
always-answer catch-all
bot/test/cari.test.mjs (NEW) — CR1–CR15: fake-transport payload capture through the real
createBot + source-introspection gates with the stripComments helper
bot/src/i18n/strings.ts — five new fields in en+ms + the /cari helpMenu line in both locales
bot/src/bot/bot.ts — ONE createGazetteerSource() shared by both registrations; origin from
env.MINI_APP_URL ?? ""; header names the second D-02 exception
bot/src/bot/handlers/location-fallback.ts — non-location messages now pass through (next)
bot/src/config.ts — MINI_APP_URL in the Env contract
bot/wrangler.jsonc — "vars": { "MINI_APP_URL": ... } (public origin, not a secret)
bot/.dev.vars.example — commented MINI_APP_URL line
Non-category log lines in cari.ts → 0; pergi_ke registrations → 0 in both cari.ts and
register-commands.mjs (D-01 reservation)
Decisions Made
Carrier correction implemented as specified (<mechanism_correction>): miniAppUrlFor(origin, id)
is the single builder; the swap to another carrier is one line if the device test fails.
Real origin, not a placeholder:MINI_APP_URL = https://araharah-bot.araharah-bot.workers.dev,
taken from the deployed WEBHOOK_URL origin (local gitignored .dev.vars; hostname only was read).
Alternates layout: the plan's authoritative code snippet puts all alternates on one row after
the OPEN row (verified fine for 4 buttons in Telegram's inline keyboard shape).
osmAttribution consumer deferred with reason: the venue card cannot carry text and the
refusal copy is pinned verbatim, so the attribution string is defined + CR11-pinned but not yet
rendered by a bot surface (see Known Stubs).
Deviations from Plan
Auto-fixed Issues
1. [Rule 1 - Bug] The GEO-02 message handler swallowed every non-location message, starving /cari
Found during: task 3 (GREEN — the fake-transport suite drove /cari KLCC through the real
createBot and recorded zero API calls)
Issue:registerLocationFallback's bot.on("message", …) matches EVERY message and returned
without calling next(). Existing commands worked only because they were registered before it;
/cari, registered after, never fired. Same-shape synthetic updates without Telegram's
bot_command entity also don't match grammY's command filter (test-side learning, fixed in the
harness).
Fix: the handler now takes grammY's next and passes non-location messages through
(if (!location) return next();), with the law documented in its doc comment.
Verification: CR1–CR8 (command path) green through the real factory; all LP*/WK* suites stay
green (the pin path is untouched).
Committed in:5dcef0e (part of the task 3 commit)
2. [Rule 1 - Bug] Gate-banned tokens appeared in comment prose (structural gates count raw source)
Found during: task 2 (editMessageText) and task 3 (GREEN — CS4, CS5b)
Issue:search-alternate.ts's header mentioned editMessageText (gate: zero occurrences);
bot.ts's new header said "inline keyboard" (CS4's /keyboard/i scan); the
register-commands.mjs header named pergi_ke (CS5b's ban is a raw-source grep).
Fix: reworded the three comments to carry the same meaning without the banned literal — the
gates' intent (no edit CALLS, no reply keyboards in commands/, no pergi_ke REGISTRATION) is intact.
Files modified: bot/src/bot/handlers/search-alternate.ts (pre-commit, 13e5073), bot/src/bot/bot.ts,
bot/scripts/register-commands.mjs (in 5dcef0e)
Total deviations: 2 auto-fixed (2 × Rule 1)
Impact on plan: Both fixes were required for the plan's own gates to hold; no scope creep. The
location-fallback pass-through is the one behavioral change outside the plan's file list and is
covered by the pre-existing LP suites.
Issues Encountered
grammY's bot.handleUpdate does NOT auto-initialize (it throws "Bot not initialized") — the
harness calls await bot.init() through the fake transport, which also proved getMe lands in
the fake (no network anywhere in the suite).
grammY's command filter requires the bot_command entity on synthetic command messages; the
harness now emits it (real Telegram always does).
Known Stubs
strings.*.osmAttribution (bot/src/i18n/strings.ts) — defined and CR11-pinned in both locales but
not yet rendered by any bot surface: sendVenue has no text field and the refusal copy is pinned
verbatim, so no honest rendering site exists in v1's chat surface. The webview already carries the
identical string (web/src/i18n/strings.ts line 73) where OSM data is actually displayed. A future
chat surface that displays OSM-derived data (e.g. Phase 5 route summaries) consumes it from here —
the strings module is deliberately the single home for copy.
Auth Gates
None — no secrets touched; the deployed origin was read from local gitignored .dev.vars (hostname
only), never echoed beyond the origin, and lives in wrangler.jsoncvars (public by design).
Threat Flags
None — the built surface is exactly the plan's threat model (T-04-21…T-04-31), and every mitigation
landed with a gate: the 128-char cap (CR7), category-only logs (CR10), alt:-only validated
callback ids (CR5/CR15 + slug regex before byId), configured-origin-only deep links (CR2), the
no-crypto law (CR3), the honest refusal (CR6), the always-answer rule (CR9), and no in-place venue
edits (CR14).
TDD Gate Compliance
NOTE — this plan realizes RED→GREEN at the PLAN level, not per-task: the plan itself sequences the
payload-shape gate suite in task 3 ("the four landmines in the SAME change"; "the suite goes RED the
moment cari.ts + the strings land"). RED evidence per task was the failing acceptance probes (task
1: missing file/strings/env greps; task 2: exactly the predicted CS4+CS5 failures, 48/2), and task
3's suite then pinned the implementation (65/65). The git log therefore shows
feat → feat → test rather than per-task test → feat pairs. No plan-level type: tdd frontmatter
exists (type: execute).
User Setup Required
None new — MINI_APP_URL ships as a public vars entry in bot/wrangler.jsonc (already set to the
deployed origin). The device gate in plan 04-04 must confirm it equals the @BotFather Mini App URL
(Bot API 10.2 enforces the match). The deployed Worker needs a wrangler deploy to pick up the new
vars + handlers — deliberately NOT done here (04-04 owns deploy + device gate).
Next Phase Readiness
Plan 04-04 (web) can build the ?dest= reader against a stable carrier: ?dest=<slug> from
location.search (primary), initData.start_param (secondary), /^[A-Za-z0-9_-]{1,64}$/
validation, unknown id → honest "destination unavailable".
The alternates/best-hit flow exercises createGazetteerSource() in the Worker bundle (verified by
the dry-run marker), so the webview and bot now share one dataset end-to-end.
/pergi_ke remains unregistered, reserved for Phase 5; the SearchSource seam is ready for the
origin/destination resolvers.
Self-Check: PASSED
All 15 touched/created files exist on disk (verified individually above).
All 3 task commits present in git log (a73a388, 13e5073, 5dcef0e); zero file deletions in
any task commit (git diff --diff-filter=D = 0 after each).
npm test -w bot re-run at close: 65 passed, 0 failed; typecheck green; web 33/33; search 57/57.