fix(06): revise plans per checker feedback — D-12 street-bearing cards by default, split 06-05 task 2, dep/wording/doc fixes

This commit is contained in:
araharah-executor
2026-10-24 08:06:42 +08:00
parent 7fe8c6aa65
commit 4212d1acd0
7 changed files with 761 additions and 59 deletions
@@ -26,6 +26,7 @@ requirements: [GUID-05]
must_haves:
truths:
- "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)"
@@ -33,19 +34,23 @@ must_haves:
- "The card's OPEN button deep-links ${MINI_APP_URL}/?g=<id> so a killed webview resumes from the envelope (GUID-06 bridge)"
artifacts:
- path: "bot/src/guidance/cron.ts"
provides: "PURE projectTurnCards + the scheduled() projection/push logic"
exports: ["projectTurnCards", "CARD_CADENCE_S"]
provides: "PURE projectTurnCards + PURE makeStreetAt graph→street resolver + the scheduled() projection/push logic"
exports: ["projectTurnCards", "makeStreetAt", "CARD_CADENCE_S"]
- path: "bot/src/webhook.ts"
provides: "scheduled() handler with KV list pagination"
provides: "scheduled() handler with KV list pagination + loadGraph-fed street reconstruction"
- path: "bot/wrangler.jsonc"
provides: "triggers.crons */1 * * * * (60 s scan; the card decision is cadence-gated in code)"
- path: "web/public/glyphs/*.png"
provides: "8 committed 256×256 turn-card PNGs served by the Worker's static assets"
key_links:
- from: "bot/src/guidance/cron.ts"
to: "routing/src (classifyTurn, decodePolyline6, deriveCumulativeDistanceM)"
via: "projection over the snapshot geometry — one turn authority shared with engine A"
pattern: "classifyTurn"
to: "routing/src (classifyTurn, decodePolyline6, deriveCumulativeDistanceM, snapToGraph, nameAt)"
via: "projection + street reconstruction over shared primitives — one turn/street authority with engine A"
pattern: "classifyTurn|snapToGraph"
- from: "bot/src/webhook.ts scheduled()"
to: "bot/src/routing/worker-router.ts"
via: "loadGraph(env) — the exported isolate-memoized R2 graph feeding makeStreetAt (no second R2 loader)"
pattern: "loadGraph"
- from: "bot/src/webhook.ts scheduled()"
to: "bot/src/guidance/cron.ts"
via: "KV list prefix guidance: → projectTurnCards → maybe sendPhoto"
@@ -80,20 +85,38 @@ Output: cron.ts + scheduled() + crons config + bot copy keys + cron suite with K
geometry (polyline6), startedAtSec }, created_at } -->
<!-- From 06-01 (routing workspace — bot already depends on it): classifyTurn(prevDeg, nextDeg),
decodePolyline6(s), deriveCumulativeDistanceM(points), snapToGraph? (NOT needed — see the
recorded call below), haversineM. Subpath-with-extension imports only. -->
decodePolyline6(s), deriveCumulativeDistanceM(points), snapToGraph(graph, point) →
{ node, distanceM } | null (grid-indexed, per-graph memoized — cheap enough for a
one-vertex snap per carded tick), nameAt(id) on RoadGraph, haversineM.
Subpath-with-extension imports only. -->
<!-- RECORDED CALL (UI-SPEC deviation, honesty-law rationale): the turn-card's maneuver line is
composed STREET-LESS — "{dist} — {action}" — because the FROZEN envelope carries geometry
only, no step names (GE2 field freeze stays; adding a 7th snapshot field is a user
decision, not a plan's). Snapping turn vertices to the graph to guess street names was
REJECTED: a wrong street name in a card is a lie; a street-less card is honest. The
UI-SPEC's "{action} onto {street}" form remains the app-side banner's job, where the
route model DOES carry steps. Record this call in the phase fidelity artifact. -->
<!-- From 06-04 (bot/src/routing/worker-router.ts — EXPORTED there): loadGraph(env):
Promise<RoadGraph | null> — the module-scope promise memo (1 R2 GET per cold isolate,
shared by /api/route AND scheduled(); null on absent/corrupt graph, never throws).
The cron imports this memo — a SECOND R2 loader is forbidden (one authority, one
cold-start cost per isolate). -->
<!-- D-12 DELIVERY (street text on the card — revised per plan-checker; supersedes the
street-less recorded call): the card's maneuver line is STREET-BEARING by DEFAULT.
The FROZEN envelope carries geometry only (GE2 field freeze holds — no steps field,
no envelope extension: path (b) is user-gated and NOT implemented), so the cron
reconstructs the street itself: snap the carded turn's vertex to the memoized R2
graph (snapToGraph) → nameAt — the SAME one-authority argument the app-side banner
relies on. DEGRADATION (D-03's designed-degradation pattern): graph absent/corrupt
in the cron isolate, or a snap miss → street-less composition with the bare verbs —
honest absence, never a guessed name. CPU tradeoff (cron-side snap per due tick vs
the free-plan budget) is a NAMED REVIEW ITEM in 06-08's blocking checkpoint: the
user ratifies the default or the street-less final state (which would then be
recorded in UI-SPEC's recorded-calls fidelity list). -->
<!-- Caption (UI-SPEC Turn-Card section, HTML, ≤1024 chars — ~120 used):
GUIDE // T-{nn} ← instrument constant, code-composed
<b>{dist} — {cardActionBare[action]}</b> ← uppercase, street-less per the recorded call
<b>{dist} — {cardAction[action]} {STREET}</b> ← DEFAULT (D-12): cardAction* verbs carry
ONTO/KE; STREET = graph-reconstructed,
uppercase; nameAt's unnamed id →
cardStreetUnnamed ("UNNAMED ROAD")
… degraded form (graph absent / snap miss):
<b>{dist} — {cardActionBare[action]}</b> ← street-less honest absence (D-03 pattern)
ETA {HH:MM} · {km} KM LEFT ← cardEtaTemplate
Best-effort projection — updates while the app is minimized. ← cardHonesty
Inline keyboard: [{ text: cardOpenButton, web_app: { url: "<MINI_APP_URL>/?g=<id>" } }]
@@ -105,15 +128,17 @@ Output: cron.ts + scheduled() + crons config + bot copy keys + cron suite with K
at second ~0 of each minute). Stateless: zero cursor keys, zero KV writes. -->
<!-- Bot copy keys (bot/src/i18n/strings.ts, en + ms, UI-SPEC pinned):
cardAction* (8) are the DEFAULT street-bearing verbs (UI-SPEC verbatim):
cardActionDepart "DEPART"/"MULA" · cardActionStraight "CONTINUE ON"/"TERUS KE" ·
cardActionLeft "TURN LEFT ONTO"/"BELOK KIRI KE" · cardActionRight "TURN RIGHT ONTO"/"BELOK KANAN KE" ·
cardActionSlightLeft "KEEP LEFT ONTO"/"SEDAKIT KIRI KE" · cardActionSlightRight "KEEP RIGHT ONTO"/"SEDAKIT KANAN KE" ·
cardActionUturn "MAKE A U-TURN ONTO"/"BUAT PUSARAN KE" · cardActionArrive "ARRIVE AT"/"TIBA DI"
(kept verbatim for the copy gate + future street-bearing use)
cardActionBare* (8) are the graph-absent DEGRADATION verbs (street-less honest absence):
cardActionBareDepart "DEPART"/"MULA" · cardActionBareStraight "CONTINUE"/"TERUS" ·
cardActionBareLeft "TURN LEFT"/"BELOK KIRI" · cardActionBareRight "TURN RIGHT"/"BELOK KANAN" ·
cardActionBareSlightLeft "KEEP LEFT"/"SEDAKIT KIRI" · cardActionBareSlightRight "KEEP RIGHT"/"SEDAKIT KANAN" ·
cardActionBareUturn "MAKE A U-TURN"/"BUAT PUSARAN" · cardActionBareArrive "ARRIVE"/"TIBA"
cardStreetUnnamed "UNNAMED ROAD"/"JALAN TANPA NAMA" ← nameAt's unnamed id, instrument voice
cardEtaTemplate "ETA {time} · {km} KM LEFT"/"ETA {masa} · {km} KM LAGI"
cardHonesty "Best-effort projection — updates while the app is minimized."/"Unjuran cuba terbaik — dikemas kini semasa aplikasi diminimumkan."
cardOpenButton "OPEN GUIDANCE"/"BUKA PANDUAN" -->
@@ -134,9 +159,10 @@ Output: cron.ts + scheduled() + crons config + bot copy keys + cron suite with K
<read_first>
- 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 (the projection's building blocks — import, never re-implement)
- 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)
- .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)
</read_first>
<behavior>
- 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
@@ -144,24 +170,27 @@ Output: cron.ts + scheduled() + crons config + bot copy keys + cron suite with K
- 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, contains NO street names (street-less recorded call), register zero-padded
- 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
</behavior>
<action>
1. Bot copy: append the 22 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.
3. GREEN: `bot/src/guidance/cron.ts` — header states the pure law ("projectTurnCards is PURE: injected nowSec, no KV, no Telegram, no clock reads — Node-testable"); `projectTurnCards(envelope: GuidanceSessionEnvelope, nowSec: number): { 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 (HTML escape NOT needed: all parts are code-composed from numbers + string-table constants — assert no envelope-derived string like destinationName enters the caption in v1). GlyphKindName is cron.ts's local union mirroring the 8 PNG names. CATEGORY LAW: the module has no console at all (pure), the wiring logs (task 2).
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).
</action>
<verify>
<automated>npm test -w bot</automated>
</verify>
<acceptance_criteria>
- 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, no-street-name assertion, D-13 banned-words scan
- bot/src/guidance/cron.ts imports classifyTurn from @araharah/routing (grep) — no local turn classifier
- bot/src/i18n/strings.ts carries cardAction* (8, UI-SPEC verbatim) + cardActionBare* (8) + cardEtaTemplate + cardHonesty + cardOpenButton in en + ms; copy gate green
- 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)
</acceptance_criteria>
<done>Projection is proven pure: progress, next turn, ETA, cadence, and the honest caption are all Node-testable with zero infrastructure</done>
<done>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</done>
</task>
<task type="auto" tdd="true">
@@ -170,12 +199,15 @@ Output: cron.ts + scheduled() + crons config + bot copy keys + cron suite with K
<read_first>
- 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`)
</read_first>
<behavior>
- 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
- offline send capture: createBot(env, { baseFetch: recording fake }) — the recorded sendPhoto args show url `${MINI_APP_URL}/glyphs/<kind>.png`, caption matching the task-1 anatomy, parse_mode HTML, reply_markup inline_keyboard with web_app url `${MINI_APP_URL}/?g=<envelope id>`
- 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/<kind>.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=<envelope id>`
- 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)
@@ -183,21 +215,22 @@ Output: cron.ts + scheduled() + crons config + bot copy keys + cron suite with K
</behavior>
<action>
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, baseFetch recording; assert the behavior list.
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; per-key: `getGuidanceSession` → `projectTurnCards(env, nowSec)` → 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.
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).
</action>
<verify>
<automated>npm test -w bot && npm run bot:typecheck</automated>
</verify>
<acceptance_criteria>
- guidance-cron.test.mjs scheduled() block green: due-card send recorded with envelope chat_id + glyph URL + ?g= deep link; non-due → 0 sends; malformed key skipped; one-bad-key tick survives
- 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
</acceptance_criteria>
<done>The cron is live in dev: scan → project → push, envelope-chat-only, zero KV writes, honest failure posture, and locally testable via the /cdn-cgi/local/scheduled endpoint</done>
<done>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</done>
</task>
<task type="auto">
@@ -247,21 +280,22 @@ Output: cron.ts + scheduled() + crons config + bot copy keys + cron suite with K
| 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/turn-kind to the USER'S OWN chat only; the D-13 copy gate bans "live/real-time/guaranteed". |
| — | DoS (KV budget) | scheduled() | mitigate | 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). |
| 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>
<verification>
- `npm test -w bot && npm run bot:typecheck` green (projection, scheduled wiring, copy gate, KV-budget pin)
- `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)
</verification>
<success_criteria>
Minimized sessions get honest turn-cards: the cron scans every minute, projects along the frozen snapshot, pushes token-colored glyph cards on the 3-minute cadence to the owner's chat with an OPEN deep-link that resumes a killed webview — zero KV writes, zero dishonest claims, zero renderer dependencies.
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>
<output>