From c87dc7dc140e762c25927146c6d2af2f58eeff9b Mon Sep 17 00:00:00 2001 From: araharah-executor Date: Sat, 24 Oct 2026 13:03:19 +0800 Subject: [PATCH] =?UTF-8?q?feat(06-03):=20guidance=20envelope=20service=20?= =?UTF-8?q?=E2=80=94=20TTL-only=20mint/get/delete=20beneath=20the=20frozen?= =?UTF-8?q?=20shape?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - mintGuidanceSession: crypto.randomUUID id, claim-derived chat_id arg, ONE put at guidance: 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 --- bot/src/kv/guidance-sessions.ts | 151 ++++++++++++++++++++++++++++++-- 1 file changed, 144 insertions(+), 7 deletions(-) diff --git a/bot/src/kv/guidance-sessions.ts b/bot/src/kv/guidance-sessions.ts index 135d39c..282f2c9 100644 --- a/bot/src/kv/guidance-sessions.ts +++ b/bot/src/kv/guidance-sessions.ts @@ -1,13 +1,23 @@ /* - * guidance-sessions.ts — the FROZEN Phase 6 contract, types-only (D-06/D-07). + * guidance-sessions.ts — the frozen Phase 5 envelope + its TTL-only KV service + * (D-06/D-07, GUID-05; service live as of Phase 6 plan 06-03). * ------------------------------------------------------------------------ - * THIS MODULE SHIPS NO BEHAVIOR. No KV binding, no Env field, no wrangler - * namespace, no runtime function — the envelope is designed here (ROADMAP - * §Phase 5 GUID-05/06 upstream obligation) so Phase 6 builds its service - * on a frozen shape. Phase 6's service owns every write; every write - * carries expirationTtl: GUIDANCE_SESSION_TTL_SECONDS (~2h) and the - * snapshot is INLINE (one key, one TTL) — nothing outlives 2h, nothing is + * SHAPE FROZEN (GE2): the four envelope fields and six snapshot fields below + * are the Phase 5 contract — byte-identical, a 5th property means drift. + * The SERVICE (Phase 6, plan 06-03) owns every write; every write carries + * expirationTtl: GUIDANCE_SESSION_TTL_SECONDS (~2h) and the snapshot is + * INLINE (one key, one TTL) — nothing outlives 2h, nothing is * reconstructable after expiry (D-07, the location-pin TTL precedent). + * privacy.test.mjs PG1 pins the same-line TTL law over this file. + * + * Identity law (T-06-02): chat_id is minted server-side from the Bearer + * claim — the service takes it as an argument; no endpoint may pass a + * client-supplied chat id. + * + * Defensive read law (the location-pin precedent): a missing, malformed, + * or tampered record reads as null — the cron (plan 07) can never push + * from garbage. Ids that fail the UUID shape never touch KV (V5: no + * arbitrary key probing). * * Key scheme: guidance: → JSON GuidanceSessionEnvelope */ @@ -56,3 +66,130 @@ export interface GuidanceSessionEnvelope { /** Envelope creation, seconds-since-epoch. */ created_at: number; } + +/* ———————————————————————————————————————————————————————————————————————— + * The service (Phase 6, plan 06-03) — every write TTL-only, every read + * defensive. The KV seam is structural (the sessions.ts/location-pin.ts + * precedent): production hands the existing `chat_sessions` binding — + * no new namespace (GE5), no new Env field (GE6). + * ———————————————————————————————————————————————————————————————————————— */ + +interface GuidanceSessionKv { + get(key: string): Promise; + put(key: string, value: string, options: { expirationTtl: number }): Promise; + delete(key: string): Promise; +} + +/** Envelope ids are lowercase UUIDs — anything else never constructs a KV + * key (V5: a malformed id must not become arbitrary-key probing). */ +const ENVELOPE_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; + +/** Exact persisted field sets (GE2's runtime twin) — sorted for compare. */ +const ENVELOPE_KEYS = ["chat_id", "created_at", "id", "route_snapshot"]; +const SNAPSHOT_KEYS = [ + "destinationId", + "destinationName", + "distanceM", + "durationS", + "geometry", + "startedAtSec", +]; + +function hasExactKeys(value: object, expected: readonly string[]): boolean { + const keys = Object.keys(value).sort(); + return keys.length === expected.length && expected.every((key, i) => keys[i] === key); +} + +function isUuidShape(id: string): boolean { + return ENVELOPE_ID_PATTERN.test(id); +} + +function keyFor(id: string): string { + return `guidance:${id}`; +} + +/** + * Mint an envelope under a fresh opaque id and write it ONCE, TTL on the + * call line (GE1/PG1). `chatId` is the CALLER'S CLAIM-derived chat — the + * service never learns of request bodies. `nowSec` is injectable for + * deterministic tests; production stamps the current time. + */ +export async function mintGuidanceSession( + kv: GuidanceSessionKv, + chatId: number, + snapshot: GuidanceRouteSnapshot, + nowSec: number = Math.floor(Date.now() / 1000), +): Promise { + const envelope: GuidanceSessionEnvelope = { + id: crypto.randomUUID(), + chat_id: chatId, + route_snapshot: snapshot, + created_at: nowSec, + }; + await kv.put(keyFor(envelope.id), JSON.stringify(envelope), { expirationTtl: GUIDANCE_SESSION_TTL_SECONDS }); + return envelope; +} + +/** + * Defensive read (the location-pin law): null unless the record parses, + * carries EXACTLY the frozen envelope fields with well-typed, in-range + * values. A corrupt or tampered record can never reach the cron (plan 07). + * A malformed id returns null BEFORE any KV touch (V5). + */ +export async function getGuidanceSession( + kv: GuidanceSessionKv, + id: string, +): Promise { + if (!isUuidShape(id)) return null; + const raw = await kv.get(keyFor(id)); + if (raw === null) return null; + try { + const parsed: unknown = JSON.parse(raw); + if ( + parsed && + typeof parsed === "object" && + !Array.isArray(parsed) && + hasExactKeys(parsed, ENVELOPE_KEYS) && + isEnvelopeValid(parsed as GuidanceSessionEnvelope) + ) { + return parsed as GuidanceSessionEnvelope; + } + return null; + } catch { + return null; + } +} + +/** Field law under the exact-key check: UUID-shaped id, integer chat_id and + * epoch stamps, a snapshot that itself carries exactly its six fields with + * finite non-negative metrics and non-empty strings (a 5th snapshot + * property or a negative duration reads as corruption). */ +function isEnvelopeValid(envelope: GuidanceSessionEnvelope): boolean { + if (!isUuidShape(envelope.id)) return false; + if (!Number.isSafeInteger(envelope.chat_id)) return false; + if (!Number.isSafeInteger(envelope.created_at) || envelope.created_at <= 0) return false; + const snap = envelope.route_snapshot; + if (!snap || typeof snap !== "object" || Array.isArray(snap)) return false; + if (!hasExactKeys(snap, SNAPSHOT_KEYS)) return false; + if (typeof snap.destinationId !== "string" || snap.destinationId.length === 0) return false; + if (typeof snap.destinationName !== "string" || snap.destinationName.length === 0) return false; + if (typeof snap.geometry !== "string" || snap.geometry.length === 0) return false; + if (typeof snap.distanceM !== "number" || !Number.isFinite(snap.distanceM) || snap.distanceM < 0) { + return false; + } + if (typeof snap.durationS !== "number" || !Number.isFinite(snap.durationS) || snap.durationS < 0) { + return false; + } + if (!Number.isSafeInteger(snap.startedAtSec) || snap.startedAtSec <= 0) return false; + return true; +} + +/** + * Explicit sweep — the trip-end half of the TTL law (D-14): the record is + * removed ACTIVELY, not merely left to expire. A malformed id is a silent + * no-op (nothing to delete, no key ever constructed). + */ +export async function deleteGuidanceSession(kv: GuidanceSessionKv, id: string): Promise { + if (!isUuidShape(id)) return; + await kv.delete(keyFor(id)); +}