- BA7: the shipped JS must carry EVERY committed gazetteer id (the
web half of bot/web id-set parity, T-04-39) — RED against the
pre-gazetteer bundle, green after the resolver lands in the build
- BA8: the shipped JS carries NO deep-link crypto (JS assets only —
the official Telegram SDK is external, D-11/T-04-40)
- no-storage: explicit web/src/search memory-only + no-crypto pin
- capture.mjs: the ARAHARAH-DEST signal race + the 'dest' wait mode
(state=resolved asserted, then the rendered card's text is waited
on — the card mounts at live, T-04-42) + the shell-dest-preload
leg; 8/8 expected captures
- homescreen.ts: wireHomeScreen behind the BOTH-halves Bot API 8.0
guard; 'added' hides (no nag), 'unknown' (always iOS) shows the
neutral prompt — indeterminate, never an error; homeScreenAdded
hides; a refused addToHomeScreen is caught, the affordance stays;
.btn-secondary in the existing strip — no new furniture; NO
beforeinstallprompt/manifest/service worker (T-04-38)
- main.ts: locale derivation moved above the bindings block; stage 13
reads BOTH destination carriers, seeds the memory-only holder, builds
the destination/unavailable surface (null when no id — Phase 3 boot
byte-identical, D-12); the live register shows the destination
surface; the ARAHARAH-DEST state-token signal fires unconditionally
- index.html: ?dest=<id> harness profile threaded to the mock's
startParam; the block stays inside __ARAHARAH_HARNESS__
- HS1-HS9 green (54/54), web:typecheck green
Capability guard (both halves), the four status branches ('added'
hides, 'unknown' shows with the neutral prompt, 'missed' shows,
'unsupported' nothing), tap → addToHomeScreen, homeScreenAdded →
hidden, and the browser-install-API ban. Suite load fails until
web/src/telegram/homescreen.ts exists (RED verified: 45 passed,
1 failed suite-load).
- resolve-destination.ts: reads ?dest= (URL, primary — the inline
web_app-button carrier) then initData's start_param (the signed
string, secondary — the direct-link/home-screen carrier), ONE
slug validation, dest_ prefix stripped; resolves against the
webview's own build-time @araharah/search copy; honest null for
unknown ids; module-scope memory-only holder (GEO-03/D-07)
- destination-card.ts: the confirmation + honest-unavailable cards,
createElement/textContent only, existing theme classes (T-04-32)
- webapp.ts: TelegramWebAppLike gains startParam, addToHomeScreen?,
checkHomeScreenStatus? + HomeScreenStatus (Bot API 8.0+ members)
- telegram-mock.ts: satisfies the extended contract in the same
change (startParam initData wiring, homeScreenStatus, __homeScreenAdded)
- strings.ts: 5 new copy fields in en+ms; web depends on @araharah/search
- DP1-DP12 green (45/45 web suite), web:typecheck green
Two carriers, one validation, memory-only resolution against the
webview's own build-time gazetteer copy. Suite load fails until
web/src/search/resolve-destination.ts exists (RED verified: 33 passed,
1 failed suite-load).
- commands.test.mjs: commandSources gains commands/cari.ts; CS4 documents
the SECOND inline-keyboard carve (venue card allowed, reply keyboard
still banned in commands/); CS5 menu becomes help/privacy/cari; CS5b's
future-stub ban narrows to /pergi_ke (reserved for Phase 5, D-01); CS6
registers cari+search in the alias set
- cari.test.mjs NEW: CR1-CR15 — payload shapes captured through the real
createBot + fake baseFetch (getMe included), plus source-introspection
gates with the stripComments helper: venue fields, OPEN URL = configured
origin + plain slug id, NO-CRYPTO law (D-11), one-message alternates,
callback_data discipline, honest refusal, 128-char cap, always-answer,
category-only logging (D-15), bilingual copy, registration script, swap
semantics, hostile-id drop
- register-commands.mjs: menu list gains cari (two fetch calls unchanged)
- [Rule 1] location-fallback.ts: the message handler now passes non-location
messages through (next) — it matched EVERY message and silently starved
all downstream message-stage handlers, so /cari never fired through the
real factory; caught by the fake-transport suite
- suites: bot 65/65 (15 CR + 9 CS + all prior), web 33/33, search 57/57,
bot:typecheck green
- search-alternate.ts: scoped alt: callback matcher that answers FIRST
(30-second rule), validates the id against the slug charset BEFORE the
lookup (T-04-23), resolves via source.byId() — honest null, never a
fabricated card; catch-all callback_query:data answers everything else
so Telegram never spins for 60 s (T-04-27)
- sendVenueCard: the ONE card builder shared by /cari and the swap path —
first card and swapped card structurally identical; a tap sends a NEW
sendVenue (no in-place edits — they destroy the venue payload, T-04-28)
- alternatesFor(entry, source): async — awaits search() on the entry's own
name, excludes the entry, caps at 4 (D-02)
- bot.ts: ONE createGazetteerSource() shared by both registrations (single
memoized index); origin = env.MINI_APP_URL ?? ''; header names the second
documented D-02 exception
- cari.ts now calls the shared card builder
- wrangler dry-run inlines the gazetteer (suria-klcc marker in the bundle) —
completes the L3 bundle-marker half deferred from plan 04-01
- cari.ts: /cari + /search twin, 128-char pre-search cap (T-04-21), honest
no-match refusal (D-13), single sendVenue card with inline OPEN + alternates
- deep-link carrier corrected per RESEARCH Pitfall 1: web_app button URL
carries ?dest=<plain slug id> — WebAppInfo has only a url field; no crypto
- origin from configured MINI_APP_URL only (never user input); blank origin
omits the OPEN button instead of guessing (T-04-30)
- strings.ts: searchUsage/searchNoMatch/searchQueryTooLong/searchOpenButton/
osmAttribution in en+ms; helpMenu gains the /cari line in both locales
- category-only logging law (D-15): four literal rejection categories, query
never logged; success path logs nothing
- config: MINI_APP_URL in Env + wrangler vars (public, not secret) +
.dev.vars.example + docs/CONFIGURATION.md row and sections
- bot deps gain @araharah/search (workspace link)
- docs/SEARCH-QUALITY.md: the bake-off close record — corpus v1 (75 cases) x
gazetteer v1 (30 entries), measured 2026-10-23T17:02Z via npm run search:corpus
- top-1 66/68 (97.1%), top-3 68/68 (100.0%) >= 90% threshold
- negatives refused 7/7, refusal margin 0.643 < 0.72 admission line
- per-category table (landmark/abbreviation/glued/typo/alias all 100%)
- escalation lever: None — met on first run; no abbreviations.ts row, no
alias, no corpus edit; the fixed ladder was never climbed
- coordinate verification: 10 seed entries spot-checked against Nominatim
(identifying UA, 1 req/s): 8/10 within 0.01°; bukit-bintang outlier is an
area-vs-point discrepancy (no change); pavilion-kl was a real ~2 km error
- both top-1 misses documented as expected disjoint-tier behaviour
- kl-gazetteer.json: pavilion-kl coordinate corrected (3.1478,101.695) ->
(3.149,101.7132) — Rule 1 data bug found by the prescribed Nominatim
verification; post-fix delta 0.0002°; corpus is coordinate-blind, all suites green
overpass-topup.mjs — maintainer-time repo tool, six hard laws:
- never imported by bot/src or web/src (TP1 gate); Worker reads only the committed JSON
- exactly ONE request per run: both live paths (Overpass POST, Nominatim GET)
share a single requestJson helper — one fetch( call site (TP7)
- identifying User-Agent araharah-gazetteer-topup/1.0 mandatory (missing UA = HTTP 406)
- busy/saturated/unusable response -> 'Overpass instance busy — retry later', exit 0 (TP4)
- output is a PROPOSAL only; source never names the curated file, so it cannot write it (TP6)
- --source=overpass|nominatim|fixture (--fixture implies fixture), --endpoint/OVERPASS_ENDPOINT
override, bbox-bounded single-family query pinned verbatim (3.05,101.55,3.25,101.80) (TP3)
- --fixture selects the offline path by itself (a fixture flag silently hitting the
live network would be a trap — fixed during GREEN)
overpass-response.json — committed real-shape Overpass response (6 KL elements:
nodes with lat/lon, ways/relation with center) so the test is deterministic.
json-to-sqlite.mjs — backend #2's dataset format reachable today (D-05):
- reads the same committed kl-gazetteer.json, emits kl-gazetteer.sqlite via
node:sqlite (zero added deps) with the SAME id set (CV1), plus the ODbL
notice embedded in a metadata table + NOTICE sidecar (ODbL s4.4, T-04-20)
- deterministic: rows in dataset order, no timestamps
- TP1 no 'overpass' under bot/src|web/src (never a runtime dependency)
- TP2 identifying User-Agent pinned (missing UA = HTTP 406, live-verified)
- TP3 bbox-bounded query tuple present
- TP4 busy fixture -> exit 0 + 'Overpass instance busy' soft-exit line
- TP5 committed fixture -> PROPOSAL output with name= lines + NEVER-writes law
- TP6 source never names the curated dataset file
- TP7 exactly one fetch( call site (shared helper for both live paths)
- CV1 converter preserves the gazetteer id set exactly (read back via node:sqlite)
- CV2 converter reads the committed kl-gazetteer.json
- CV3 no sqlite/json-to-sqlite under bot/src|web/src
8 cases verified failing (scripts absent) before GREEN.
- golden-corpus.json: 75 hand-written pinned cases (D-13) — landmark 17 /
abbreviation 15 / glued 7 / typo 14 / alias 15 / negative 7, incl. two
near-miss negatives (Penang 0.625, Kampung Cina ~0.58) below the 0.72
admission line; threshold.top3_hit_rate pinned at 0.9
- gazetteer-corpus.mjs: the D-14 runner — imports the SAME src/index.ts the
bot uses, measures top-1/top-3 + per-category rates + avg ms/query, prints
the refusal margin and the fixed escalation ladder on failure, exports
measureCorpus() for the in-process suite, exits 0 only on threshold + zero leaks
- corpus.test.mjs goes green: CO1-CO8 (the Nyquist gate inside npm run search:test)
- delegates: search:corpus (root) + corpus (search workspace)
Measured: top-3 68/68 (100.0%) >= 90%, negatives refused 7/7, margin 0.643 < 0.72 —
threshold met on the first run (D-14 lever: None).
- CO1 top-3 >= corpus threshold (D-14), measured in-process via the runner's measureCorpus
- CO2 zero negative leaks (honest refusal, D-13)
- CO3 tautology floor: >= 15 non-negative queries not string-equal to any alias/name
- CO4/CO5 every expectId exists in the gazetteer + start_param-safe charset
- CO6 size >= 60 + per-category floors (landmark 12 / abbreviation 15 / glued 6 / typo 12 / alias 12 / negative 6)
- CO7 per-category rates printed so suite and runner output agree
- CO8 refusal margin pinned: every negative's best two-level fuzzy sim < 0.72, one near-miss in [0.55, 0.72)
All 8 cases verified failing (ENOENT corpus fixture) before GREEN.
- kl-gazetteer.json: 30 hand-curated KL-metro landmarks (10 required + 20
more) with locals' aliases as native data (D-08) — canonical OSM names
as name, shorthand ('KLCC', 'DUKE', 'Sg Wang') as aliases — plus the
ODbL-1.0 attribution header traveling inside the JSON (D-06/D-07)
- gazetteer.ts: the three laws — lazy memoized index never at module
scope (Workers startup, T-04-04), static JSON import surviving
wrangler/Vite/bare-Node (D-07), zero logging/storage (D-15); aliases
normalized once at index-build (D-09 symmetry)
- source.ts: the D-03 SearchSource — async search() + synchronous byId()
(slug-validated, honest null); tiered admission (fuzzy only at sim
>= 0.72) so 'Kuching'/'Penang' refuse as [] — never a fabricated pin
- index.ts: the named-export public API (no default, subpath+extension
imports per the @araharah/theme precedent)
- LICENSE: the verbatim GPL-3.0 text (D-04); LICENSES.md: the dual-licence
note (code GPL-3.0, data ODbL-1.0 + OSM attribution); README: corrected
licence section + search/ in the layout, four-package table, commands
G1-G11 + L1-L3 green (39/39); typecheck clean; esbuild bundle carries the
suria-klcc marker; wrangler dry-run exits 0; bot + web suites unaffected.
- G1-G6: dataset invariants — schema, slug-id uniqueness, canonical-name
law ('KLCC' is an alias, never a name), DUKE-as-alias, KL bbox, the
ODbL header traveling inside the JSON
- G7-G9: the licensing gate — verbatim GPL-3.0 LICENSE, LICENSES.md,
the README no longer claiming 'all rights reserved'
- G10-G11: no logging/storage anywhere in the module (D-15); the loader
builds its index inside a function, never at module top level
- L1-L3: the phase's top mechanical risk — bare-Node loading with the
SRCH-01/negative probes, the static JSON import law, esbuild INLINING
the dataset + the wrangler dry-run build guard
13 cases fail while the dataset, loader, LICENSE files, and README fix
are absent — the RED gate.
- distance.ts: hand-rolled 3-band OSA, adjacent transposition = 1 edit,
zero imports (D-07); the row rotation is pinned by the D1-D9 vectors
- score.ts: TIER bands strictly disjoint descending — EXACT 1.00 /
ALIAS_EXACT 0.90 / PREFIX 0.75 / TOKEN_SET 0.65 / FUZZY as the 0.60
CEILING (a fuzzy score is fuzzySim * 0.60, never a 0.00 multiplier);
FUZZY_ADMIT_SIMILARITY = 0.72 exported for source.ts + the corpus
runner; token-level residual lets 'Sentrl' reach 'KL Sentral' while
'Jln Tun Razak' cannot reach 'Jalan Ampang'
- scoreCandidate returns the RAW matched alias (corpus provenance);
an exact ALIAS hit rides ALIAS_EXACT so a name-exact outranks it
- score.test.mjs S7 corrected during GREEN: admission belongs to
source.ts — scoreCandidate asserts the sub-floor similarity instead
D1-D9 + S1-S7 green; typecheck clean.
Phase 4 research: in-Worker SearchSource over a curated KL gazetteer.
Two critical platform findings:
- Telegram start_param is NEVER delivered to inline web_app buttons
(direct links + attachment menu only). The destination id must ride
as a URL query param on the button's URL; D-11's substance preserved.
- beforeinstallprompt never fires in a WebView. ONBD-03 must use
Telegram's native WebApp.addToHomeScreen(); iOS status is always
"unknown" by platform design.
Benchmarked the CPU budget: naive full-corpus Damerau is 52ms at
D-07's max scale vs the 10ms Workers Free limit. Exact-hash-first +
token-prefiltered residual is 0.0003ms.
Also: Phase 2 CS3/CS4/CS5 gates need declared amendments; ODbL
attribution obligation for the OSM-derived gazetteer.
- theme.ts: colorScheme → data-tg-theme on <html>; header_bg_color 0-1 luminance fallback
(T-03-11 — Telegram hex is a signal, never a consumed CSS value); applyTheme's ONLY
statement is the attribute set (Pitfall 7 / D-10, greppable invariant)
- backbutton.ts: hide() at init (one shell state), onClick is total — close() at the root,
no dead-ends (UI-SPEC §BackButton)
- safe-area.ts: env(safe-area-inset-*) probe ∪ WebApp.safeAreaInset, per-edge max,
version-gated at 8.0, applied as --safe-* custom properties on :root
- webapp.ts + backbutton/safe-area never read node_modules types;
@types/telegram-web-app stays dropped (web/package.json untouched)
- kv/location-pin.ts mirrors sessions.ts shape: writePin/getPin/clearPin, key loc:<chat_id> (chat-scoped, claim-derived — never user-id scoped across keys)
- PIN_TTL_SECONDS=300; writePin is the only write path, expirationTtl on the same line referencing the named constant
- getPin is a defensive read: parse guard + shape guard + |lat|<=90 / |lon|<=180 range guard (V5 Input Validation) — corrupt records return null
- privacy.test.mjs: storageModules now scans kv/location-pin.ts, so PG1 (same-line TTL) and PG2 (no persistence-shaped exports) apply automatically
- Deploy walkthrough revealed a bare workers.dev origin setWebhook answers ok but drops updates at / (404)
- Normalize WEBHOOK_URL: no path -> append /telegram/webhook; invalid URL -> fail fast, never echo values (T-02-04)
- .dev.vars.example documents the path expectation
- sessions.ts: mintSession (crypto.randomUUID opaque token, T-02-05), rotate-on-new deletes prior session record via chat pointer (D-07), both puts carry expirationTtl: SESSION_TTL_SECONDS on the call line (PRIV-02)
- SESSION_TTL_SECONDS = 900 pairs the session envelope with the D-13 freshness window
- SessionClaim is exactly D-08: user_id, chat_id, issued_at, language_code — nothing else serializes to storage
- sessions.test.mjs: in-memory KV fake asserting finite expirationTtl on every put; D-08 key-set equality; rotate removes old token; expired-token → null (D-14 class)
- privacy.test.mjs: PG1 same-line expirationTtl source gate (auto-tightens to webhook.ts when plan 03 adds it) + PG2 no persistence-shaped exports
- 18/18 PASS across initdata + sessions + privacy suites; full plan GREEN
- bot/ npm workspace sibling to theme/: grammy ^1.46.0, wrangler ^4.151.3, TS ^5.9.3
- src/config.ts exports Env (BOT_TOKEN, WEBHOOK_SECRET, chat_sessions KVNamespace)
- wrangler.jsonc: main src/webhook.ts + chat_sessions KV binding (id filled in plan 03)
- .gitignore ships .dev.vars/.wrangler/ in the same commit as .dev.vars.example (D-17)
- root delegate scripts bot:test/typecheck/dev/deploy/commands
- implementation pages' style="…" attributes are rendered CSS: every
declaration checked like <style>-block CSS, violations reported as
file:line: style attribute { prop: value }
- token-var references pass by construction; only off-token literals flag
- 16/16 audit tests green; real build still AUDIT CLEAN
- T8: style="color: #00ff00" on an implementation page currently passes
silently — an evasion path around the <style>-block scan (Rule 2 hole
in the D-13 evidence: the gallery itself will use style attributes)