- G1 activation gate: sub-threshold fix keeps the Phase 5 route state;
a fix >= 30 m along mounts the cockpit (data-guidance, close protection,
banner + card, the locked camera pose, state=on_route token)
- G2 pump updates: banner distance/street from the model, ETA card,
stepping progress bar (pct = alongM/totalM)
- G3 no premature signal during the OFF_ROUTE grace; G4 REROUTING calm
swap + dash + gantry with exactly ONE source.route call under the storm
guard; G5 pending precondition + NO REROUTE fail-honest degradation;
G6 reroute success = setData snap-in, NO camera jump, gantry stop
- G7 END TRIP -> confirm -> YES ABANDON teardown (watch stop, protection
release, trip-end card, state=ended); CONTINUE dismisses, guidance alive
- G8 arrival runs the same teardown from the machine's arrival effect
- RS6 re-pinned: banned tokens only on ../guidance/ import lines (guidance
IMPLEMENTATION lives in web/src/guidance/); RS8 re-pinned: the
ARAHARAH-GUIDE token family joins the console discipline
- guidance/camera.ts: GUIDANCE_CAMERA_PADDING {320,64,64,64} / ZOOM 16 /
EASE_MS 400 / PITCH 35; easeToSnapped rides the follow controller (ONE
easing discipline) and never passes bearing — north-up, recorded call;
onRerouteApplied is the documented camera no-op (setData lives in the
route layer)
- map/follow.ts: follow(point, opts?) pose overrides spread over the
Phase 3/5 defaults — no-opts calls stay byte-identical (RS13); the
re-lock announces the mode (the recenter notch's return signal, RS12);
the originalEvent unlock law untouched
- telegram/backbutton.ts: BackAction resolver ({hidden|close|callback})
consulted per click + wire.sync() visibility re-apply (visible iff
actionable); the legacy (hasOverlay, walkBack) shape composes internally
so Phase 5 call sites and RS7 stay valid; close() only at the true root
- RS7b: the context-aware BackButton resolver walks search → routes →
guidance → (confirm) → routes → search; close() NEVER fires from a
pushed state; the wire exposes sync() for state changes
- RS11: GUIDANCE_CAMERA_PADDING/{ZOOM,EASE_MS,CAMERA_PITCH} pinned at the
UI-SPEC pose; bearing key NEVER present; onRerouteApplied moves nothing
- RS12: programmatic moves never unlock follow; a user movestart
(originalEvent) unlocks — Phase 3 free state returns
- RS13: follow(fix) without opts stays byte-identical (no pitch/zoom keys)
- fakeMap grows flyTo/on/emit recording; suite header documents the RS6/RS8
re-pins landing with the guidance wiring (tasks 2-3)
- buildGuidanceCard: GUIDE register, verbatim headline, live telemetry row (MIN / {km} KM / ETA), 4px progressbar with full aria value set + instant inline width, no-reroute state line only when degrading, ONE btn-secondary END TRIP
- buildConfirmCard: GUIDE // CONFIRM + exact en/ms privacy copy, btn-primary YES ABANDON + btn-secondary CONTINUE, guide-confirm bottom-register hook — the ONE primary CTA in guidance chrome
- buildTripEndCard: GUIDE // END, exact deletion copy, no actions
- shell.css guidance block: banner chassis + 1px separator, CSS-only strip compaction under [data-guidance], list panel + row states + lamp, progress bar — token vars only, zero motion/depth/radius/hex (theme audit clean)
- 8 hand-authored 32x32 SVGs per the UI-SPEC geometry table (3px stroke, square caps, orthogonal/45° only; uturn = machined staple, no arc)
- glyphs.ts: GLYPH_KINDS/GLYPHS via Vite ?raw (committed files are the single source) + svgHost — the ONE sanctioned innerHTML site, a static import value
- strings.ts: 18 ShellStrings keys appended in en + ms, values verbatim from the 06-UI-SPEC copywriting contract
- copy-gate: COPY1 floor 40→58 + COPY8 pins all 18 guidance keys verbatim in both locales
- upload-road-graph.mjs: wrangler-shelled maintainer tool (pre-flight 1-80MB,
round-trip verify hint); bucket araharah-graphs NOT created — R2 is not yet
enabled on the account (wrangler code 10042) — dashboard one-time activation
is the phase's user_setup item
- measure-route-cpu.mjs: 25-pair gate (corpus + seeded quadrant spread),
hrtime around routeAstar, warm-up excluded, unreachable probes reported
SEPARATELY from the routed p95, exits 1 over the 8ms budget
- Rule 1 (measured): the class set lacked *_link ramps — the graph FRAGMENTED
(7.7% of nodes in severed motorway islands; corpus pairs 'unreachable').
5 link classes added -> 311325 nodes / 677068 edges / 14.7 MB, all landmarks
in one component; enforced floor corrected 1KB -> 1MB (D-01)
- ladder (a) drop tertiary+residential: p95 17.8ms but 12/25 pairs unreachable
— REJECTED on correctness (D-03), reverted, ladder history recorded
- ladder (b) bidirectional A* (BH, Ikeda potentials, reverse-CSR WeakMap):
provably optimal (A1 625-pair sweep green), alternation balanced by heap
size (raw-top comparison starves the backward side 19501:3), reduction
signs in the standard feasible form. Measured: p95 ~38-93ms — NO GATE PASS.
The potential split halves each side's steering, cancelling the halved radius
- GATE FAIL is the honest verdict: ladder (c) — Workers Paid vs layout-v2 ALT
(still $0) vs free-plan-with-degradation — requires maintainer sign-off;
docs/ROAD-GRAPH.md carries the decision table and the fork contract
- bot/src/routing/worker-router.ts: module-scope per-env graph memo (1 R2 GET per
cold isolate, shared with the 06-07 cron arm via loadGraph), snap+A*+deriveSteps,
claim-gated 401 / 4096-cap 413 / V5 bbox 400, 200-null on every miss class,
ONE best route (no alternatives), category-only logs
- webhook.ts: /api/route arm delegating to handleRoute (405 non-POST)
- config.ts: ROAD_GRAPH: R2Bucket — first R2 binding (never a KV namespace, GE5)
- wrangler.jsonc: r2_buckets ROAD_GRAPH -> araharah-graphs with the fork law comment
- routing/src/http-source.ts: createHttpRoutingSource — slug-guard-first, fail-honest
(throw/non-200/malformed/null-body -> null), defensive RouteResult validation;
exported from index.ts; ladder note: backend #2 LIVE
- A7 re-pinned (06-01 named 06-04 as the owner of createHttpRoutingSource)
- test-side fix during GREEN: the R2 fake hands decodeGraph an exact ArrayBuffer
(Node Buffers are views; encodeGraph output is already an ArrayBuffer)
- bot 103 passed / routing 65 passed; both typechecks clean
- POST: WR-01 cap (MAX_GUIDANCE_BODY_CHARS 8192, pre/post-read double check),
hand-rolled snapshot validation (slug destinationId, bounded name, finite
metrics, integer startedAtSec, polyline6 geometry >= 2 points via
decodePolyline6) -> mintGuidanceSession with the CLAIM's chat_id — a body
chat_id is structurally ignored (T-06-02 injection probe GW4)
- GET/DELETE: resolve then OWN — envelope.chat_id === claim.chat_id else 404,
foreign and absent indistinguishable (T-06-02b); DELETE actively removes
the record (D-14's server half); non-POST/GET/DELETE -> 405
- category-only logging: [guidance] rejected: unauthorized|malformed —
literal strings, no interpolation of body/claim values (D-14)
- RP2 re-pin ships WITH the change (gate re-pin law): the total
@araharah/routing ban becomes an explicit allowlist — exactly the
polyline6 codec may enter the Worker (geometry validation); the routing
stack stays web-side; RP1 unchanged
- bot/package.json + lock: @araharah/routing workspace dep
- offline undici Request/Response pattern; claims minted through the real
/api/session path (mintSession) against the Map-fake KV
- GW8 green-by-construction (the catch-all already 404s absent ids);
ownership teeth live in GW7/GW10
- mintGuidanceSession: crypto.randomUUID id, claim-derived chat_id arg,
ONE put at guidance:<id> with same-line expirationTtl (GE1/PG1)
- getGuidanceSession: UUID-shape guard BEFORE any KV touch (V5), exact
4+6-field + type + finite/range validation (the location-pin defensive
read law) — corrupt records read as null, never reach the cron
- deleteGuidanceSession: active trip-end sweep (D-14's server half)
- module header: the types-only note becomes the shape-frozen + service-
owns-every-write law; envelope/snapshot interfaces byte-identical (GE2)
- no new namespace (GE5), no new Env field (GE6) — the chat_sessions
binding is handed in structurally
- GE1/GE2/GE3 UNCHANGED in meaning (TTL 7200, 4+6 field freeze, inline snapshot)
- GE4 re-pinned: types-only token ban -> the module must EXPORT mint/get/deleteGuidanceSession
- GE7 re-pinned: total consumer ban -> named-imports-only of the service surface
- GE5/GE6 stand (exactly one kv namespace; no Env guidance field)
- GS1-GS5 behavioral: TTL'd mint, roundtrip, 7-class defensive null read,
UUID key-guard without KV touch (V5), active delete (D-14 server half)
- PG1 storageModules now covers kv/guidance-sessions.ts (same-line TTL law)
- shouldAcceptFix: accept when first fix, >=10 m moved, or >=5 s elapsed
(whichever first) — the pure accept rule over haversineM
- createGuidanceWatch(geo, onFix): high-accuracy watchPosition
(maximumAge: 0), six-field Fix normalization (null accuracy/heading,
source webview), rejected fixes die in the wrapper, idempotent stop()
clearing the watcher id (T-06-05b's privacy-minimal trip end)
- replay suite T1-T3 + R12b: truth table, recorded-options structural
gate, feed-only-accepted proof, stop idempotence, module purity scan
- snapToGraph: bbox+margin guard first (honest null = router-pending,
D-03), then 3x3 coarse-grid neighborhood (0.002 deg cells, WeakMap
memo per RoadGraph — LAW 1 lazy memoize, O(local) not O(n))
- projection in cos(lat)-scaled degree space (Pitfall 8); returned
distance is haversineM; result carries the best segment's nameId
(null for the unnamed sentinel) for plan 07's cron street-hop
- oneway-reverse edges stay snap-visible (direction is A*'s law);
gridBuildCount() instrumentation kept internal to the workspace
- Pinned-layout gates: round-trip, bad magic/version/truncated -> null,
zero-copy view mutation, name pool, committed mini-graph fixture pin
- Each case lazy-imports ../src/graph.ts — ERR_MODULE_NOT_FOUND is the
red state (fixture-source.test.mjs pattern)
- RP1: zero /api/route + registerRoute/routeProxy tokens in comment-stripped
bot/src — the Worker gains no route endpoint, even accidentally (T-05-16)
- RP2: zero @araharah/routing specifiers in bot/src — routing lives in the
webview bundle only; the Worker imports only @araharah/search
- scans grow with the bot/src tree automatically (the PG-gate pattern)
- TDD note: gates target an already-clean tree, so the RED run is a teeth
proof — a planted violating file fails both gates; removed, both pass
(74/0 bot suite green)
- route-profiles.ts: HARNESS-ONLY module — sentinel ROUTE_PROFILE_HARNESS_ONLY
+ getRouteProfileOrigin wrapping getPairOrigin; D-12 header (pinned PUBLIC
origin only, no coordinate params) + BA5 exclusion note
- index.html: ?fixpair=<pair-id> knob resolves the pair's pinned public
origin as the mock's telegramFix (accuracy 12, heading null); unknown
pairId keeps the default fix; knob documented in the profile comment
- [Rule 1 - Bug] main.ts never passed bootMap's opts.onLoad — route sources
never existed and setRouteDrawn's setData silently no-op'd (cards rendered,
the line never painted; invisible to the RS fakes). Wiring added + RS2
boot-wiring gate pins it
- [Rule 1 - Bug] consult raced the map load: harness session stubs resolve
the location chain before WebGL/style init, so draw landed on missing
sources and the post-load empty install discarded it (probe: ROUTE token
fired before RAMS-BASEMAP-READY). Consult now awaits mapLayersReady,
resolved in the load handler after installRouteLayers (never wedges —
finally-resolved; cards-only boots resolve immediately)
- Capture run: CAPTURE OK — 13/13; route line verified painted, alt swap
renders ROUTE 2/2 +1 MIN VS ROUTE 1, pending card honest, dark PX-02
- npm test -w web: 76 passed (new RS2 boot-wiring gate included)
- SIGNALS + seen-map + diagnostics gain ARAHARAH-ROUTE; waitMode 'route'
races the state token, asserts expectState + expectText DOM text, and
applies the PX-02 theme-slot honesty check on themed legs
- captureLeg gains an interact(page) pre-shot hook (the alt leg's chip tap)
- five legs data-driven from the committed corpus (demoPair picked at
runtime — never a hardcoded pair literal): fixture / alt / pending /
dark / route-gallery-compare (composer refactored into composeCompare)
- expected list grows 8 → 13
- RED by construction: the ?fixpair knob does not exist yet, so the
default fix sits outside the demo pair's 200m tolerance → the drawn
legs must fail with state=pending
- BA9: summed gz size of fixtures dataset + routing/src + web/src/route
sources <= 204800 bytes, measurement printed every run (D-11, T-05-12)
- BA10: every committed pairId must appear in the shipped JS assets
(the BA7 analog — a stale fixture-less bundle is unshippable)
- BA5: ROUTE_PROFILE_HARNESS_ONLY joins the harness-leak token scan
(the 05-05 route-profiles module must be dead-code-eliminated from dist)
- TDD note: gates target artifacts already shipped by 05-02/05-04, so no
failing RED run exists; mechanism teeth proven (bogus pairId absent)
- one test per check, mirroring search/test/corpus.test.mjs: the suite imports
measurePairs from routing/scripts/route-corpus.mjs IN-PROCESS (never spawns
the CLI) — one measurement, two reporters, no drift
- RED: the measurement core does not exist yet -> 10 named ERR_MODULE_NOT_FOUND
failures; the committed dataset itself is already green material (task 1)
- fully offline by construction: zero network in the measurement path (T-05-05)
- L3 previously pinned the 05-01 empty-seed state (honest null at the pinned origin)
- it now asserts the default fixture source SERVES pair 1 (kl-tower-to-kl-sentral)
at its pinned public origin and resolves honest null ~300 m away (outside the
200 m tolerance, D-03)
- RED until plan 05-02 populates the committed fixture dataset
- GuidanceSessionEnvelope: EXACTLY id, chat_id, route_snapshot, created_at
(SessionClaim field-freeze law — a 5th property means drift)
- GuidanceRouteSnapshot INLINE (D-07: one key, one TTL, ~2h): destination
id/name, distanceM/durationS, polyline6 geometry (05-01 storage format),
startedAtSec resume anchor
- GUIDANCE_SESSION_TTL_SECONDS = 7200 — every Phase 6 write carries it
- guidance:<id> key-scheme doc header; zero runtime behavior (no binding,
no function, no import — GE4 pins it)
Note: header says 'No KV binding' rather than naming the KV type because
the acceptance gate requires that token to appear NOWHERE in the file,
comments included — same law, gate-clean wording.
- synthetic injected datasets only — never the network, never the committed file
- F3 pins the 200 m haversine tolerance boundary; F6 the defensive pair skip
- F7 the lazy-index source gate (LAW 1); L2 the esbuild inline marker
- standard encoded-polyline algorithm at 1e6 precision, double math
(no int32 shifts) so no coordinate range wraps silently
- decode THROWS on truncated varints — never partial/NaN points (T-05-02)
- round-trip law encode(decode(s)) === s proven on all vectors incl.
the external live-OSRM geometry (RESEARCH Finding 1)
- hand-derived vectors from the plan's <polyline_vectors> block
- external live-OSRM demo geometry (RESEARCH Finding 1) as ground truth
- P7 pins the truncated-varint refusal law (T-05-02)
- deploy 31138f79 live at https://araharah-bot.araharah-bot.workers.dev;
MINI_APP_URL == origin; menu button web_app armed; commands
re-registered (help/privacy/cari); unsigned webhook 401; the live
app chunk carries 30/30 gazetteer ids + zero crypto tokens
- getWebhookInfo last_error is STALE (2026-10-22, phase-3 era,
pre-dates both wave deploys; pending 0) — re-read at leg 1
- all six physical legs recorded UNVERIFIED — the phase stays open
until a real Android/iOS/Desktop walks them; no faked verdicts
- 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.