test(06-03): re-pin GE4/GE7 to the live service + behavioral envelope suite + PG1 storage list (RED)
- 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)
This commit is contained in:
@@ -1,43 +1,48 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
/*
|
/*
|
||||||
* guidance-envelope.test.mjs — GE1–GE7 types-only gates for the frozen
|
* guidance-envelope.test.mjs — the guidance_sessions contract gates (D-06/D-07).
|
||||||
* guidance_sessions contract (D-06/D-07).
|
* ------------------------------------------------------------------------------
|
||||||
* ---------------------------------------------------------------------
|
* The envelope SHAPE is the Phase 5 frozen contract: GE1/GE2/GE3 stay UNCHANGED
|
||||||
* The envelope is a TYPES-ONLY frozen contract this phase (D-06): code
|
* in meaning (TTL 7200; exactly 4 envelope fields + 6 snapshot fields; inline
|
||||||
* types + the ~2h TTL constant + the key-scheme doc. NO KV namespace, NO
|
* snapshot). Phase 6 plan 06-03 takes the service LIVE, so the boundary gates
|
||||||
* service, NO runtime writes — Phase 6 builds the backgrounded turn-card
|
* re-pin WITH the change that invalidates them (the gate re-pin law):
|
||||||
* service on the frozen shape. These gates pin that boundary MECHANICALLY:
|
|
||||||
*
|
*
|
||||||
* GE1 TTL constant — GUIDANCE_SESSION_TTL_SECONDS === 7200 (the
|
* GE1 TTL constant — GUIDANCE_SESSION_TTL_SECONDS === 7200 (UNCHANGED;
|
||||||
* ~2h law; every future write carries it)
|
* every write carries it)
|
||||||
* GE2 field freeze — GuidanceSessionEnvelope has EXACTLY
|
* GE2 field freeze — GuidanceSessionEnvelope has EXACTLY
|
||||||
* [id, chat_id, route_snapshot, created_at] and
|
* [id, chat_id, route_snapshot, created_at] and
|
||||||
* GuidanceRouteSnapshot has EXACTLY
|
* GuidanceRouteSnapshot has EXACTLY
|
||||||
* [destinationId, destinationName, distanceM,
|
* [destinationId, destinationName, distanceM,
|
||||||
* durationS, geometry, startedAtSec] (the
|
* durationS, geometry, startedAtSec] (UNCHANGED; the
|
||||||
|
* structural scan fails on any 5th property — the
|
||||||
* SessionClaim "a 5th property means drift" law)
|
* SessionClaim "a 5th property means drift" law)
|
||||||
* GE3 inline snapshot — route_snapshot is TYPED INLINE
|
* GE3 inline snapshot — route_snapshot is TYPED INLINE
|
||||||
* (GuidanceRouteSnapshot, not a reference) and
|
* (GuidanceRouteSnapshot, not a reference) and
|
||||||
* the tokens snapshotRef / snapshot_ref appear
|
* the tokens snapshotRef / snapshot_ref appear
|
||||||
* NOWHERE in bot/src (D-07: one key, one TTL)
|
* NOWHERE in bot/src (UNCHANGED; D-07: one key,
|
||||||
* GE4 types-only — comments stripped, the module contains zero
|
* one TTL)
|
||||||
* behavior tokens (no KV binding type, no
|
* GE4 the service — RE-PINNED (06-03; was the types-only behavior-
|
||||||
* put/get/delete, no function export, no import)
|
* token ban): the module exports mint/get/delete
|
||||||
* GE5 no new namespace — wrangler.jsonc carries EXACTLY ONE kv
|
* GuidanceSession + the TTL constant — the service
|
||||||
* namespace (chat_sessions)
|
* owns every write
|
||||||
* GE6 no Env field — config.ts gains no guidance field this phase
|
* GE5 no new namespace — wrangler.jsonc carries EXACTLY ONE kv namespace
|
||||||
* GE7 no premature — no bot/src module imports the contract; only
|
* (chat_sessions): the service reuses it; R2 later
|
||||||
* consumer the test does (Phase 6 builds the consumer)
|
* is a different binding class, never a 2nd KV
|
||||||
|
* GE6 no Env field — config.ts gains no guidance field: the service is
|
||||||
|
* handed the EXISTING chat_sessions binding
|
||||||
|
* GE7 consumer shape — RE-PINNED (06-03; was the total consumer ban):
|
||||||
|
* bot/src MAY import the service, but every import
|
||||||
|
* must be a NAMED import of the service surface
|
||||||
|
* (mint/get/delete, the TTL constant, the two
|
||||||
|
* types) — no default/namespace/side-effect
|
||||||
|
* end-run. Vacuous until the first consumer lands
|
||||||
|
* (this plan's webhook task); teeth grow with the
|
||||||
|
* consumer tree.
|
||||||
*
|
*
|
||||||
* RED-shape note: GE1–GE4 fail while the module is absent. GE5–GE7 are
|
* The behavioral block pins the SERVICE LAWS against a Map-fake KV
|
||||||
* negative-space boundary guards over files that must NOT change — they
|
* (sessions.test.mjs style): TTL'd mint, defensive null reads (the
|
||||||
* are green by construction and redden only on drift (the 05-01 C4/C5/C6
|
* location-pin law — corrupt never reaches the cron), the UUID key-guard
|
||||||
* zero-laws precedent).
|
* (V5: no arbitrary key probing), and active delete (D-14's server half).
|
||||||
*
|
|
||||||
* Phase 6 note: when the turn-card service legitimately adds the kv
|
|
||||||
* namespace binding, the Env field, and the consumer, updating GE4–GE7 is
|
|
||||||
* THAT plan's responsibility — these gates redden until then (T-05-08:
|
|
||||||
* the contract cannot acquire a runtime shadow this phase).
|
|
||||||
*/
|
*/
|
||||||
import { strict as assert } from "node:assert";
|
import { strict as assert } from "node:assert";
|
||||||
import { readdirSync, readFileSync } from "node:fs";
|
import { readdirSync, readFileSync } from "node:fs";
|
||||||
@@ -128,25 +133,30 @@ test("GE3 inline snapshot — route_snapshot is typed inline; zero snapshot-ref
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
/* ————— GE4 — the module ships NO behavior (T-05-08) ————— */
|
/* ————— GE4 — RE-PINNED (06-03): the service is LIVE beneath the frozen shape ————— */
|
||||||
|
|
||||||
test("GE4 types-only — comments stripped, zero behavior tokens in guidance-sessions.ts", () => {
|
test("GE4 the service — the frozen contract exports mint/get/delete + the TTL constant (re-pinned 06-03)", async () => {
|
||||||
const code = stripComments(readFileSync(MODULE, "utf8"));
|
const mod = await import("../src/kv/guidance-sessions.ts");
|
||||||
const banned = [
|
assert.equal(
|
||||||
"KvNamespace",
|
typeof mod.mintGuidanceSession,
|
||||||
".put(",
|
"function",
|
||||||
".get(",
|
"mintGuidanceSession must exist — the envelope service is LIVE (06-03; was types-only)",
|
||||||
".delete(",
|
);
|
||||||
"export function",
|
assert.equal(
|
||||||
"export async function",
|
typeof mod.getGuidanceSession,
|
||||||
"import ",
|
"function",
|
||||||
];
|
"getGuidanceSession must exist — the cron's defensively-validated read",
|
||||||
for (const token of banned) {
|
);
|
||||||
assert.ok(
|
assert.equal(
|
||||||
!code.includes(token),
|
typeof mod.deleteGuidanceSession,
|
||||||
`types-only violation: behavior token "${token}" in guidance-sessions.ts (T-05-08: premature persistence path)`,
|
"function",
|
||||||
);
|
"deleteGuidanceSession must exist — D-14's server half (active trip-end deletion)",
|
||||||
}
|
);
|
||||||
|
assert.equal(
|
||||||
|
mod.GUIDANCE_SESSION_TTL_SECONDS,
|
||||||
|
7200,
|
||||||
|
"the TTL constant survives the service — every write still carries it",
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
/* ————— GE5 — exactly one kv namespace (chat_sessions) ————— */
|
/* ————— GE5 — exactly one kv namespace (chat_sessions) ————— */
|
||||||
@@ -158,31 +168,166 @@ test("GE5 no new namespace — wrangler.jsonc carries EXACTLY ONE kv namespace:
|
|||||||
assert.equal(
|
assert.equal(
|
||||||
namespaces.length,
|
namespaces.length,
|
||||||
1,
|
1,
|
||||||
"a second kv namespace appeared — the guidance envelope is TYPES-ONLY this phase (D-06)",
|
"a second kv namespace appeared — the guidance service reuses chat_sessions (R2 is a different binding class)",
|
||||||
);
|
);
|
||||||
assert.equal(namespaces[0].binding, "chat_sessions", "the single namespace must remain chat_sessions");
|
assert.equal(namespaces[0].binding, "chat_sessions", "the single namespace must remain chat_sessions");
|
||||||
});
|
});
|
||||||
|
|
||||||
/* ————— GE6 — the Env contract gains nothing this phase ————— */
|
/* ————— GE6 — the Env contract gains nothing ————— */
|
||||||
|
|
||||||
test("GE6 no Env field — config.ts contains no guidance token", () => {
|
test("GE6 no Env field — config.ts contains no guidance token", () => {
|
||||||
const config = readFileSync(CONFIG, "utf8");
|
const config = readFileSync(CONFIG, "utf8");
|
||||||
assert.ok(
|
assert.ok(
|
||||||
!config.includes("guidance"),
|
!config.includes("guidance"),
|
||||||
"config.ts gained a guidance field (D-06: Env gains NOTHING this phase — Phase 6's plan owns that change)",
|
"config.ts gained a guidance field — the service is handed the EXISTING chat_sessions binding; no new Env surface",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
/* ————— GE7 — no premature consumer ————— */
|
/* ————— GE7 — RE-PINNED (06-03): consumer imports constrained to the service surface ————— */
|
||||||
|
|
||||||
test("GE7 no premature consumer — zero bot/src imports of guidance-sessions (only the test imports it)", () => {
|
test("GE7 consumer shape — every bot/src import of guidance-sessions is a NAMED import of the service surface", () => {
|
||||||
const IMPORT_RE = /\bimport\b[^;\n"']*["'][^"']*guidance-sessions[^"']*["']/;
|
const ALLOWED = new Set([
|
||||||
|
"mintGuidanceSession",
|
||||||
|
"getGuidanceSession",
|
||||||
|
"deleteGuidanceSession",
|
||||||
|
"GUIDANCE_SESSION_TTL_SECONDS",
|
||||||
|
"GuidanceSessionEnvelope",
|
||||||
|
"GuidanceRouteSnapshot",
|
||||||
|
]);
|
||||||
|
const NAMED_RE = /\bimport\s+(?:type\s+)?\{([^}]*)\}\s*from\s*["'][^"']*guidance-sessions[^"']*["']/g;
|
||||||
for (const file of tsFilesUnder(srcDir)) {
|
for (const file of tsFilesUnder(srcDir)) {
|
||||||
const code = stripComments(readFileSync(file, "utf8"));
|
const code = stripComments(readFileSync(file, "utf8"));
|
||||||
assert.doesNotMatch(
|
const named = [...code.matchAll(NAMED_RE)];
|
||||||
code,
|
const specifiers = code.match(/["'][^"']*guidance-sessions[^"']*["']/g) ?? [];
|
||||||
IMPORT_RE,
|
assert.equal(
|
||||||
`${file} imports the guidance contract (D-06: no consumer until Phase 6 builds the service)`,
|
specifiers.length,
|
||||||
|
named.length,
|
||||||
|
`${file} — a default/namespace/side-effect import of guidance-sessions is banned; import NAMED service surface only`,
|
||||||
);
|
);
|
||||||
|
for (const [, names] of named) {
|
||||||
|
for (const raw of names.split(",")) {
|
||||||
|
const name = raw.trim().replace(/^type\s+/, "");
|
||||||
|
if (name === "") continue;
|
||||||
|
assert.ok(
|
||||||
|
ALLOWED.has(name),
|
||||||
|
`${file} imports "${name}" — only the service surface may cross (mint/get/delete, the TTL constant, the two types)`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/* ————— behavioral — the service laws (Map-fake KV, sessions.test.mjs style) ————— */
|
||||||
|
|
||||||
|
function makeKV() {
|
||||||
|
const map = new Map();
|
||||||
|
const ttlLog = [];
|
||||||
|
let getCalls = 0;
|
||||||
|
return {
|
||||||
|
async get(key) {
|
||||||
|
getCalls += 1;
|
||||||
|
return map.has(key) ? map.get(key) : null;
|
||||||
|
},
|
||||||
|
async put(key, value, opts) {
|
||||||
|
assert.ok(
|
||||||
|
opts && Number.isFinite(opts.expirationTtl) && opts.expirationTtl > 0,
|
||||||
|
`put(${key}) must carry a finite positive expirationTtl`,
|
||||||
|
);
|
||||||
|
ttlLog.push({ key, expirationTtl: opts.expirationTtl });
|
||||||
|
map.set(key, value);
|
||||||
|
},
|
||||||
|
async delete(key) {
|
||||||
|
map.delete(key);
|
||||||
|
},
|
||||||
|
map,
|
||||||
|
ttlLog,
|
||||||
|
get getCalls() {
|
||||||
|
return getCalls;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const SNAPSHOT = {
|
||||||
|
destinationId: "suria-klcc",
|
||||||
|
destinationName: "Suria KLCC",
|
||||||
|
distanceM: 8432,
|
||||||
|
durationS: 900,
|
||||||
|
geometry: "oqv_Egt~~`Ewj@_q@", // polyline6, two KL points (routing/src/polyline.ts codec)
|
||||||
|
startedAtSec: 1792817000,
|
||||||
|
};
|
||||||
|
|
||||||
|
test("GS1 mint: uuid id, created_at stamp, EXACTLY ONE put at guidance:<id> with expirationTtl 7200, envelope exactly 4 fields", async () => {
|
||||||
|
const mod = await import("../src/kv/guidance-sessions.ts");
|
||||||
|
const kv = makeKV();
|
||||||
|
const envelope = await mod.mintGuidanceSession(kv, 101, SNAPSHOT, 1792817600);
|
||||||
|
assert.match(envelope.id, /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/, "opaque UUID id (T-02-05 law)");
|
||||||
|
assert.deepEqual(
|
||||||
|
Object.keys(envelope).sort(),
|
||||||
|
["chat_id", "created_at", "id", "route_snapshot"],
|
||||||
|
"the minted envelope is exactly the frozen 4-field shape (GE2's runtime twin)",
|
||||||
|
);
|
||||||
|
assert.equal(envelope.chat_id, 101, "chat_id comes from the caller's CLAIM, never a body");
|
||||||
|
assert.equal(envelope.created_at, 1792817600, "created_at is the injected now, seconds-since-epoch");
|
||||||
|
assert.deepEqual(envelope.route_snapshot, SNAPSHOT, "the snapshot is stored verbatim, INLINE");
|
||||||
|
assert.equal(kv.ttlLog.length, 1, "mint is exactly one KV write");
|
||||||
|
assert.equal(kv.ttlLog[0].key, `guidance:${envelope.id}`, "key scheme: guidance:<id>");
|
||||||
|
assert.equal(kv.ttlLog[0].expirationTtl, mod.GUIDANCE_SESSION_TTL_SECONDS, "every write carries the TTL constant (GE1)");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("GS2 get: a well-formed record round-trips the minted envelope", async () => {
|
||||||
|
const mod = await import("../src/kv/guidance-sessions.ts");
|
||||||
|
const kv = makeKV();
|
||||||
|
const minted = await mod.mintGuidanceSession(kv, 101, SNAPSHOT, 1792817600);
|
||||||
|
assert.deepEqual(await mod.getGuidanceSession(kv, minted.id), minted);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("GS3 defensive read: missing key, malformed JSON, a 5th envelope field, a missing field, wrong-typed chat_id, non-finite distanceM, negative durationS — ALL null (corrupt never reaches the cron)", async () => {
|
||||||
|
const mod = await import("../src/kv/guidance-sessions.ts");
|
||||||
|
const kv = makeKV();
|
||||||
|
assert.equal(
|
||||||
|
await mod.getGuidanceSession(kv, "3f2a2c3e-1b4d-4e5a-9c8d-000000000001"),
|
||||||
|
null,
|
||||||
|
"missing key → null",
|
||||||
|
);
|
||||||
|
const minted = await mod.mintGuidanceSession(kv, 101, SNAPSHOT, 1792817600);
|
||||||
|
const key = `guidance:${minted.id}`;
|
||||||
|
const base = JSON.parse(kv.map.get(key));
|
||||||
|
const tamper = (mutate) => kv.map.set(key, JSON.stringify(mutate(base)));
|
||||||
|
tamper((r) => ({ ...r, extra: 1 }));
|
||||||
|
assert.equal(await mod.getGuidanceSession(kv, minted.id), null, "a 5th envelope field → null");
|
||||||
|
tamper(({ created_at: _dropped, ...rest }) => rest);
|
||||||
|
assert.equal(await mod.getGuidanceSession(kv, minted.id), null, "a missing field → null");
|
||||||
|
tamper((r) => ({ ...r, created_at: 1792817600, chat_id: "101" }));
|
||||||
|
assert.equal(await mod.getGuidanceSession(kv, minted.id), null, "wrong-typed chat_id → null");
|
||||||
|
tamper((r) => ({ ...r, chat_id: 101, route_snapshot: { ...r.route_snapshot, distanceM: "far" } }));
|
||||||
|
assert.equal(await mod.getGuidanceSession(kv, minted.id), null, "non-finite distanceM → null");
|
||||||
|
tamper((r) => ({ ...r, route_snapshot: { ...r.route_snapshot, distanceM: 8432, durationS: -5 } }));
|
||||||
|
assert.equal(await mod.getGuidanceSession(kv, minted.id), null, "negative durationS → null");
|
||||||
|
kv.map.set(key, "{not json");
|
||||||
|
assert.equal(await mod.getGuidanceSession(kv, minted.id), null, "malformed JSON → null");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("GS4 key-guard: ids failing the UUID shape read as null WITHOUT touching KV (V5: no arbitrary key probing)", async () => {
|
||||||
|
const mod = await import("../src/kv/guidance-sessions.ts");
|
||||||
|
const kv = makeKV();
|
||||||
|
const badIds = [
|
||||||
|
"",
|
||||||
|
"../../session:abc",
|
||||||
|
"3F2A2C3E-1B4D-4E5A-9C8D-000000000001", // uppercase — the guard is lowercase-exact
|
||||||
|
"3f2a2c3e-1b4d-4e5a-9c8d-00000000000", // one hex short
|
||||||
|
"3f2a2c3e-1b4d-4e5a-9c8d-00000000000g", // one hex over
|
||||||
|
];
|
||||||
|
for (const id of badIds) {
|
||||||
|
assert.equal(await mod.getGuidanceSession(kv, id), null, `guard rejects ${JSON.stringify(id)}`);
|
||||||
|
}
|
||||||
|
assert.equal(kv.getCalls, 0, "KV was never consulted for a malformed id");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("GS5 delete: the key is actively removed (D-14's server half) — a get after reads null", async () => {
|
||||||
|
const mod = await import("../src/kv/guidance-sessions.ts");
|
||||||
|
const kv = makeKV();
|
||||||
|
const minted = await mod.mintGuidanceSession(kv, 101, SNAPSHOT, 1792817600);
|
||||||
|
await mod.deleteGuidanceSession(kv, minted.id);
|
||||||
|
assert.equal(kv.map.has(`guidance:${minted.id}`), false, "the record is gone, not merely expired");
|
||||||
|
assert.equal(await mod.getGuidanceSession(kv, minted.id), null);
|
||||||
|
});
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ import { test } from "./harness.mjs";
|
|||||||
|
|
||||||
const SRC = path.join(fileURLToPath(new URL(import.meta.url)), "..", "..", "src");
|
const SRC = path.join(fileURLToPath(new URL(import.meta.url)), "..", "..", "src");
|
||||||
|
|
||||||
const storageModules = ["kv/sessions.ts", "kv/location-pin.ts"]
|
const storageModules = ["kv/sessions.ts", "kv/location-pin.ts", "kv/guidance-sessions.ts"]
|
||||||
.map((rel) => path.join(SRC, ...rel.split("/")));
|
.map((rel) => path.join(SRC, ...rel.split("/")));
|
||||||
const webhookModule = path.join(SRC, "webhook.ts");
|
const webhookModule = path.join(SRC, "webhook.ts");
|
||||||
if (existsSync(webhookModule)) storageModules.push(webhookModule);
|
if (existsSync(webhookModule)) storageModules.push(webhookModule);
|
||||||
|
|||||||
Reference in New Issue
Block a user