feat(06-03): guidance envelope service — TTL-only mint/get/delete beneath the frozen shape

- 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
This commit is contained in:
araharah-executor
2026-10-24 13:03:19 +08:00
parent 0cfa0bde38
commit c87dc7dc14
+144 -7
View File
@@ -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:<id> → 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<string | null>;
put(key: string, value: string, options: { expirationTtl: number }): Promise<void>;
delete(key: string): Promise<void>;
}
/** 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<GuidanceSessionEnvelope> {
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<GuidanceSessionEnvelope | null> {
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<void> {
if (!isUuidShape(id)) return;
await kv.delete(keyFor(id));
}