A Worker Cron Trigger scans guidance:* envelopes every 60 s, projects progress along the snapshot, and pushes a glyph+text turn-card on its 3-minute cadence (D-11/D-12)
Card maneuver lines carry the STREET by DEFAULT — reconstructed by snapping the carded turn's vertex to the memoized R2 graph (snapToGraph + nameAt, per D-12's ETA/distance/street text); street-less bare-verb cards ONLY when the graph is absent in the isolate or the snap misses (D-03-style honest degradation)
Cards push ONLY to envelope.chat_id read from KV — no client-supplied chat id exists anywhere in the cron path (T-06-02)
The cron performs ZERO KV writes — the stateless cadence holds the 1,000-writes/day free budget (Pitfall 3)
Card copy never promises live tracking — 'best-effort projection' is the strongest claim (D-13)
The 8 committed glyph PNGs exist, token-colored, built from the SAME committed SVG sources the app renders (D-12 single source)
The card's OPEN button deep-links ${MINI_APP_URL}/?g=<id> so a killed webview resumes from the envelope (GUID-06 bridge)
path
provides
exports
bot/src/guidance/cron.ts
PURE projectTurnCards + PURE makeStreetAt graph→street resolver + the scheduled() projection/push logic
projectTurnCards
makeStreetAt
CARD_CADENCE_S
path
provides
bot/src/webhook.ts
scheduled() handler with KV list pagination + loadGraph-fed street reconstruction
path
provides
bot/wrangler.jsonc
triggers.crons */1 * * * * (60 s scan; the card decision is cadence-gated in code)
path
provides
web/public/glyphs/*.png
8 committed 256×256 turn-card PNGs served by the Worker's static assets
projection + street reconstruction over shared primitives — one turn/street authority with engine A
classifyTurn|snapToGraph
from
to
via
pattern
bot/src/webhook.ts scheduled()
bot/src/routing/worker-router.ts
loadGraph(env) — the exported isolate-memoized R2 graph feeding makeStreetAt (no second R2 loader)
loadGraph
from
to
via
pattern
bot/src/webhook.ts scheduled()
bot/src/guidance/cron.ts
KV list prefix guidance: → projectTurnCards → maybe sendPhoto
projectTurnCards
from
to
via
pattern
routing/scripts/build-glyphs.mjs
web/src/guidance/glyphs/*.svg
the committed SVGs are the single source; PNGs are their rasterization
glyphs
GUID-05's backgrounded half: the repo's first Cron Trigger scanning live guidance envelopes, projecting progress along the frozen snapshot geometry, and pushing honest glyph+text turn-cards to the user's own chat — plus the committed 8-glyph PNG set built from the app's SVG sources.
Purpose: navigation survives the app being minimized, best-effort and honestly framed (D-13). The envelope lifecycle is structural (plan 06): no key, no push.
Output: cron.ts + scheduled() + crons config + bot copy keys + cron suite with KV-budget pins + 8 committed PNGs.
@.planning/phases/06-turn-by-turn-guidance-live-driving/06-UI-SPEC.md
@.planning/phases/06-turn-by-turn-guidance-live-driving/06-CONTEXT.md
@.planning/phases/06-turn-by-turn-guidance-live-driving/06-RESEARCH.md
@.planning/phases/06-turn-by-turn-guidance-live-driving/06-PATTERNS.md
task 1: projectTurnCards (pure) + bot copy keys
bot/src/guidance/cron.ts, bot/src/i18n/strings.ts, bot/test/guidance-cron.test.mjs, bot/test/commands.test.mjs
- bot/src/i18n/strings.ts (the BotStrings interface + copy-gate law — append keys, never inline)
- bot/test/commands.test.mjs (the bot copy gate — extend for the new keys in the same change)
- routing/src/astar.ts classifyTurn + routing/src/polyline.ts + routing/src/geo.ts + routing/src/snap.ts (the projection's + street reconstruction's building blocks — import, never re-implement)
- routing/test/fixtures/build-mini-graph.mjs + the committed mini-graph fixture (the street-present test's graph: its named way supplies the expected street string)
- bot/src/kv/guidance-sessions.ts (the frozen snapshot this projects — geometry + startedAtSec + distanceM/durationS)
- .planning/phases/06-turn-by-turn-guidance-live-driving/06-RESEARCH.md "Turn-card send" + UI-SPEC Turn-Card Chat Message section (caption anatomy + `{action} onto {street}` line)
- progress projection: given envelope with known geometry (a synthetic 3-leg polyline with bearing changes at known points), distanceM 10000, durationS 900, startedAtSec = T: projectTurnCards(env, T+300) → progressM ≈ 10000×(300/900), remainingM ≈ 6667, etaClock = now + 600 s rounded to HH:MM
- next-turn detection: the FIRST classified turn (classifyTurn over consecutive-leg bearings) whose along-distance ≥ progressM → kind + zero-padded index in the register (GUIDE // T-03)
- remaining distance to that turn = its along-distance − progressM, formatted "{n} M" / "{n.n} KM"
- cadence: nowSec % 180 < 60 → due true (e.g. nowSec = 3600 → true; 3660 → false); CARD_CADENCE_S = 180 exported
- progress ≥ distanceM (trip would have ended) → due false, no card (the cron never cards a dead trip)
- caption: exactly 4 lines per the anatomy, HTML parse mode, ≤ 1024 chars, contains the honesty line verbatim, register zero-padded
- street-bearing DEFAULT (D-12): with a streetAt resolver returning a name, the maneuver line is "{dist} — {cardAction[action]} {STREET}" (the verb carries ONTO/KE; street uppercased); nameAt's unnamed id → cardStreetUnnamed ("UNNAMED ROAD"/"JALAN TANPA NAMA")
- street-less DEGRADATION: streetAt omitted or null for the carded vertex → "{dist} — {cardActionBare[action]}" — bare verbs, honest absence, same 4-line anatomy
- makeStreetAt(graph) is PURE (graph in → resolver out; snapToGraph + nameAt imported from @araharah/routing, zero I/O): against decodeGraph of the committed mini-graph fixture, a vertex on the fixture's named way resolves that way's name; a vertex off-graph → null
- copy law: caption contains none of "live", "real-time", "guaranteed" (case-insensitive) — D-13's strongest-claim gate
1. Bot copy: append the 23 keys from the context box to `BotStrings` + both locale records (verbatim values); extend the copy gate in `bot/test/commands.test.mjs` to assert them in en + ms.
2. RED: `bot/test/guidance-cron.test.mjs` — build a synthetic envelope (geometry via encodePolyline6 of a known 3-leg path with 90° and 45° turns), assert every behavior above with fixed nowSec values. Street cases: (a) fake resolver `streetAt = () => "JALAN SULTAN ISMAIL"` → the ONTO line composes with that street; (b) `streetAt` omitted / returns null → the bare-verb line; (c) mini-graph integration — construct the synthetic envelope geometry ALONG the mini-graph fixture's named way so the carded turn's vertex snaps deterministically onto it, run the REAL `makeStreetAt(decodeGraph(miniGraphBuffer))`, and assert the caption contains the fixture way's name.
3. GREEN: `bot/src/guidance/cron.ts` — header states the pure law ("projectTurnCards and makeStreetAt are PURE: injected nowSec/graph, no KV, no R2, no Telegram, no clock reads — Node-testable"); `projectTurnCards(envelope: GuidanceSessionEnvelope, nowSec: number, streetAt?: (vertexIdx: number) => string | null): { due: boolean; glyph: GlyphKindName | null; caption: string | null; maneuverIdx: number | null }` — decode geometry → cumulative → progressM (clamped to distanceM) → walk bearings (bearing changes between segments; skip degenerate sub-m points) → classifyTurn → first turn ≥ progressM (none → "straight" glyph, index 00) → compose caption via strings + the exact anatomy: street string present → `{cardAction[action]} {street}`; null/absent resolver → `{cardActionBare[action]}`. Also export `makeStreetAt(graph: RoadGraph): (vertexIdx: number) => string | null` — snap the geometry vertex at vertexIdx (snapToGraph) → `graph.nameAt(node)`; snap miss → null; unnamed id → the cardStreetUnnamed constant. GlyphKindName is cron.ts's local union mirroring the 8 PNG names. HTML-escape surface stays ZERO: all caption parts are code-composed from numbers + string-table constants — assert no envelope-derived string (e.g. destinationName) enters the caption; street text arrives only via makeStreetAt's graph lookup. CATEGORY LAW: the module has no console at all (pure), the wiring logs (task 2).
npm test -w bot
- guidance-cron.test.mjs green: projection math, next-turn kind + zero-padded index, cadence table, dead-trip no-card, 4-line caption ≤ 1024, honesty-line present, street-bearing line asserted against the mini-graph fixture (real makeStreetAt), street-less bare-verb case asserted in the graph-absent test, unnamed-id → cardStreetUnnamed, D-13 banned-words scan
- bot/src/guidance/cron.ts imports classifyTurn + snapToGraph from @araharah/routing (grep) — no local turn classifier, no local snapper
- bot/src/i18n/strings.ts carries cardAction* (8, UI-SPEC verbatim) + cardActionBare* (8) + cardStreetUnnamed + cardEtaTemplate + cardHonesty + cardOpenButton in en + ms; copy gate green
- grep `console\.` → 0 matches in bot/src/guidance/cron.ts (pure module)
Projection is proven pure: progress, next turn, ETA, cadence, and the honest caption — street-bearing by default (D-12), street-less only as designed degradation — are all Node-testable with zero infrastructure
task 2: scheduled() handler + crons config + offline send tests + KV-budget pin
bot/src/webhook.ts, bot/wrangler.jsonc, bot/test/guidance-cron.test.mjs
- bot/src/webhook.ts (the ExportedHandler fetch skeleton — scheduled() extends the SAME object; the fail-closed secret posture lines 58–71)
- bot/src/bot/bot.ts createBot(env, { baseFetch }) (the injectable-fetch factory the offline tests capture sends through)
- bot/src/routing/worker-router.ts (06-04's EXPORTED loadGraph(env) memo — the ONLY R2 graph loader the cron may use)
- bot/src/config.ts (MINI_APP_URL var — the glyph URL + deep link base)
- .planning/phases/06-turn-by-turn-guidance-live-driving/06-RESEARCH.md "Cron config + scheduled handler" code example + Pitfall 3 (KV write budget) + local-test endpoint (`/cdn-cgi/local/scheduled`)
- scheduled() lists KV prefix "guidance:" with cursor pagination (list_complete respected), gets each envelope via getGuidanceSession (malformed → skip + `[guidance-cron] skipped: malformed` category log), projects, and for due cards calls bot.api.sendPhoto with chat_id FROM THE ENVELOPE ONLY
- street reconstruction wiring (D-12 default): scheduled() awaits loadGraph(env) ONCE per tick (the worker-router memo — no second R2 loader), and when the graph resolved passes streetAt = makeStreetAt(graph) into projectTurnCards so cards carry the street; graph null (absent/corrupt R2) → streetAt omitted → the street-less bare-verb degradation card — the send still fires (degradation, not silence; D-03 pattern)
- snapping is BOUNDED: only the carded turn's vertex is snapped per carded envelope (makeStreetAt called for that index, not a walk of the whole geometry) — the cron CPU tradeoff this creates is a named review item in 06-08's blocking checkpoint
- offline send capture: createBot(env, { baseFetch: recording fake }) — the recorded sendPhoto args show url `${MINI_APP_URL}/glyphs/.png`, caption matching the task-1 anatomy (street-bearing under the R2 mini-graph fake; bare-verb under the R2-null fake), parse_mode HTML, reply_markup inline_keyboard with web_app url `${MINI_APP_URL}/?g=`
- non-due tick → zero sends; absent/failed envelope → skip, never throw (one bad key cannot kill the tick)
- KV-BUDGET PIN (Pitfall 3): structural scan — bot/src/guidance/cron.ts + the scheduled() arm contain ZERO `.put(` calls (stateless law; the gate lives in guidance-cron.test.mjs)
- wrangler.jsonc gains `"triggers": { "crons": ["*/1 * * * *"] }`; kv_namespaces stays exactly ONE entry (GE5)
- fail-closed: missing BOT_TOKEN → scheduled() logs `[guidance-cron] skipped: unconfigured` and returns (the webhook fail-closed posture)
1. `bot/wrangler.jsonc`: add the triggers block with a law comment ("60 s scan; card cadence decided in code (CARD_CADENCE_S 180) — D-11's 60–120 s window; local test: curl \"http://localhost:8787/cdn-cgi/local/scheduled\"").
2. RED: extend `bot/test/guidance-cron.test.mjs` with the scheduled() block — invoke the exported `scheduled(controller, env, ctx)` directly (controller = `{ cron: "*/1 * * * *" } as ScheduledController`), env = KV Map-fake seeded with 3 envelopes (one due, one not-due, one malformed string) + a valid BOT_TOKEN + an R2 fake (`{ get: async () => ({ arrayBuffer: async () => miniGraphBuffer }) }` — the 06-04 webhook-test shape), baseFetch recording; assert the behavior list INCLUDING both street states (mini-graph fake → caption carries the fixture street; a second run with the R2 fake returning null → bare-verb caption, send still recorded).
3. GREEN in `bot/src/webhook.ts`: add `async scheduled(controller, env, ctx)` to the ExportedHandler — fail-closed config check (BOT_TOKEN + chat_sessions present, else category log + return); paginated KV list; `const graph = await loadGraph(env).catch(() => null)` once per tick (import from worker-router — a corrupt/absent graph degrades to the street-less card, never fails the tick); per-key: `getGuidanceSession` → `projectTurnCards(env, nowSec, graph ? makeStreetAt(graph) : undefined)` → due ? `createBot(env).api.sendPhoto(...)` inside try/catch (delivery variance is documented best-effort, D-13 — a failed send logs `[guidance-cron] send failed` and continues). ctx.waitUntil wraps the whole body. Category-only logs throughout.
4. Keep `npm run bot:typecheck` green (ScheduledController/ExportedHandler types from @cloudflare/workers-types).
npm test -w bot && npm run bot:typecheck
- guidance-cron.test.mjs scheduled() block green: due-card send recorded with envelope chat_id + glyph URL + ?g= deep link; R2 mini-graph fake → caption carries the fixture street; R2-null fake → bare-verb street-less caption with the send still recorded; non-due → 0 sends; malformed key skipped; one-bad-key tick survives
- structural KV-budget pin: grep `\.put\(` → 0 matches in bot/src/guidance/cron.ts AND in the scheduled() arm of webhook.ts (assert in-test, PATTERNS PG style)
- grep `loadGraph` matches the scheduled() arm (the shared memo — no second R2 loader: grep for a second `ROAD_GRAPH.get` outside worker-router.ts → 0 matches in bot/src)
- bot/wrangler.jsonc has triggers.crons ["*/1 * * * *"]; kv_namespaces length still 1 (GE5 green)
- D-13 copy law holds in the wired path (caption from projectTurnCards, unchanged)
- bot:typecheck clean
The cron is live in dev: scan → project → push with street-bearing cards from the shared graph memo (street-less only when the graph is absent), envelope-chat-only, zero KV writes, honest failure posture, and locally testable via the /cdn-cgi/local/scheduled endpoint
task 3: build-glyphs.mjs — the committed 8-glyph PNG set (sharp)
routing/scripts/build-glyphs.mjs, routing/package.json, web/public/glyphs/*.png
- web/src/guidance/glyphs/*.svg (06-05's committed sources — THE single source these PNGs rasterize)
- routing/scripts/fetch-route-fixtures.mjs (the maintainer-tool shell: header, Usage, Exit codes, collect-then-write)
- .planning/phases/06-turn-by-turn-guidance-live-driving/06-UI-SPEC.md "The 8-glyph set" production paragraph (256×256, #EB8008 on #1A1A1A full-bleed, committed beside the static assets)
- `npm run glyphs` (script added to routing/package.json: "glyphs": "node scripts/build-glyphs.mjs") produces web/public/glyphs/{depart,straight,left,right,slight-left,slight-right,uturn,arrive}.png
- each PNG is 256×256, non-trivial size (> 500 bytes), visually the token-colored glyph on the matte-black plate
- the script recolors currentColor → #EB8008 and injects a full-bleed #1A1A1A plate — the SOURCE SVGs stay untouched (currentColor law for the app's theme-following rendering)
- any missing/invalid source SVG → exit 1, NO partial writes (collect-then-write)
1. `npm install -D -w routing sharp` (research Installation — devDependency only, zero runtime deps added; verify `npm ls sharp` shows it under routing).
2. Write `routing/scripts/build-glyphs.mjs` in the maintainer shell: header names the D-12 boundary + the single-source law; named constants `GLYPH_KINDS` (the 8), `STROKE = "#EB8008"`, `PLATE = "#1A1A1A"`, `SIZE = 256` (hex literals allowed HERE — build constants copied verbatim from tokens.css, the DSGN-01 precedent; the script is not runtime CSS); read each SVG, string-substitute `currentColor` → STROKE and inject `` as the first child of `
npm run glyphs && node -e "const fs=require('fs');const kinds=['depart','straight','left','right','slight-left','slight-right','uturn','arrive'];for(const k of kinds){const s=fs.statSync('web/public/glyphs/'+k+'.png');if(s.size<500)process.exit(1)};console.log('8 PNGs ok')"
- all 8 web/public/glyphs/*.png exist, each > 500 bytes, committed
- routing/package.json has sharp in devDependencies and the "glyphs" script
- the SVG sources are unmodified after a run (git status clean for web/src/guidance/glyphs/)
- the script carries the maintainer header + named color constants (#EB8008 / #1A1A1A verbatim from tokens.css)
The chat card's visual language is committed: 8 crisp token-colored glyphs rasterized once from the same sources the banner renders — no renderer dependency, no runtime cost
<threat_model>
Trust Boundaries
Boundary
Description
Cron tick → Telegram API
The Worker pushes only to chat ids read from defensively-validated KV envelopes
KV envelopes → cards
A malformed record must never produce a card (or a crash)
STRIDE Threat Register
Threat ID
Category
Component
Disposition
Mitigation Plan
T-06-02
Spoofing / Information Disclosure (cross-chat push)
scheduled() → sendPhoto
mitigate
chat_id comes ONLY from getGuidanceSession's validated envelope (06-03's defensive read); no request path can influence the recipient; the offline test asserts the recorded send's chat_id equals the seeded envelope's.
T-06-04
Information Disclosure (logs + card copy)
cron logging + caption
mitigate
Category-only logs ([guidance-cron] skipped: malformed) — never chat ids, coordinates, or payloads; the card carries ETA/distance/street/turn-kind to the USER'S OWN chat only; street text comes from the repo's own R2 graph (OSM-derived public data), never from the envelope; the D-13 copy gate bans "live/real-time/guaranteed".
—
DoS (KV budget + cron CPU)
scheduled()
mitigate / review
STATELESS: zero KV writes per tick (structural pin); reads are 1 list + N gets per minute — comfortably inside 100 k reads/day at v1 scale (A8). The street reconstruction adds 1 graph memo (1 R2 GET/cold isolate) + ONE snapToGraph per carded tick — inside the free-plan CPU budget by design, and surfaced as a NAMED ratification item in 06-08's blocking checkpoint (street-less ratification path recorded there).
T-06-03
Tampering (glyph assets)
web/public/glyphs/*.png
accept
Committed repo artifacts built by a maintainer script from committed sources; the Worker serves its own repo's assets — no untrusted upload path.
—
Repudiation
—
accept
Cards are transactional messages to the owning chat (the Out-of-Scope table bans engagement pushes); nothing persistent beyond the TTL'd envelope.
</threat_model>
- `npm test -w bot && npm run bot:typecheck` green (projection + street reconstruction, scheduled wiring, copy gate, KV-budget pin)
- Street states proven: mini-graph-backed caption carries the fixture street; graph-absent case produces the bare-verb honest card (both asserted in guidance-cron.test.mjs)
- `npm run glyphs` green; 8 committed PNGs; SVG sources untouched
- wrangler.jsonc: crons configured, exactly one KV namespace (GE5)
- Local cron smoke (dev): `npx wrangler dev` + `curl "http://localhost:8787/cdn-cgi/local/scheduled"` documented in the SUMMARY (requires a seeded local envelope — optional manual smoke)
<success_criteria>
Minimized sessions get honest turn-cards: the cron scans every minute, projects along the frozen snapshot, and pushes token-colored glyph cards on the 3-minute cadence to the owner's chat — maneuver lines street-bearing by DEFAULT via the shared memoized graph (D-12), bare-verb street-less only as designed degradation — with an OPEN deep-link that resumes a killed webview, zero KV writes, zero dishonest claims, zero renderer dependencies.
</success_criteria>
After completion, create `.planning/phases/06-turn-by-turn-guidance-live-driving/06-07-SUMMARY.md`