fix(02-bot-skeleton-initdata-security): WR-03 honest rotate-on-new invariant + best-effort post-mint reconciliation (fold in IN-01b dead branch, IN-01c Phase-4 notes)
This commit is contained in:
@@ -12,6 +12,9 @@
|
||||
* SM2 mint: every put carries expirationTtl === SESSION_TTL_SECONDS (900)
|
||||
* SR1 rotate: second mint for same chat removes the old session key
|
||||
* SR2 rotate: pointer targets the new token; exactly one live record
|
||||
* SR3 the honest KV limit (WR-03): interleaved mints racing the same
|
||||
* pointer can orphan a loser record for at most SESSION_TTL_SECONDS;
|
||||
* the next sequential mint sweeps the pointer's predecessor
|
||||
* SG1 getClaimByToken round-trips (language_code "ms" and null)
|
||||
* SG2 unknown token → null (the "expired" rejection class, D-14)
|
||||
*/
|
||||
@@ -82,7 +85,7 @@ test("SM2 mint: every put carries expirationTtl === 900 (PRIV-02 by construction
|
||||
}
|
||||
});
|
||||
|
||||
/* ————— SR1/SR2 — rotate-on-new (T-02-02 replay close) ————— */
|
||||
/* ————— SR1/SR2/SR3 — rotate-on-new (T-02-02 replay close) ————— */
|
||||
|
||||
test("SR1 rotate: minting again for the same chat removes the old session key", async () => {
|
||||
const kv = makeKV();
|
||||
@@ -96,17 +99,80 @@ test("SR1 rotate: minting again for the same chat removes the old session key",
|
||||
);
|
||||
});
|
||||
|
||||
test("SR2 rotate: pointer targets the new token; exactly one live session record", async () => {
|
||||
test("SR2 rotate (sequential): pointer targets the new token; exactly one live session record", async () => {
|
||||
const kv = makeKV();
|
||||
const token1 = await mod.mintSession(kv, claim(104, "en"));
|
||||
const token2 = await mod.mintSession(kv, claim(104, "en"));
|
||||
const pointer = await mod.getTokenForChat(kv, 104);
|
||||
assert.equal(pointer, token2, "chat pointer must point at the newest token");
|
||||
const liveSessions = [...kv.map.keys()].filter((k) => k.startsWith("session:"));
|
||||
assert.equal(liveSessions.length, 1, "one live token per chat (D-07 invariant)");
|
||||
assert.equal(
|
||||
liveSessions.length,
|
||||
1,
|
||||
"one live token per chat (D-07) — sequential shape; see sessions.ts header for the WR-03 eventual-consistency caveat",
|
||||
);
|
||||
assert.equal(liveSessions[0], `session:${token2}`);
|
||||
});
|
||||
|
||||
/** SR3 emulation of the KV race the review confirmed (no transactions,
|
||||
* eventual consistency): both racing mints read the SAME pre-write
|
||||
* pointer, so neither sees the other's writes. B's pointer read is parked
|
||||
* mid-flight — it snapshots `chat:<id>` before any write — while A's
|
||||
* entire mint lands; when B resumes, its stale view makes it blind to
|
||||
* A's token, exactly the stale-replica shape under KV. */
|
||||
test("SR3 (WR-03, documented KV limit): interleaved mints can orphan a loser record for ≤ SESSION_TTL_SECONDS; the next sequential mint sweeps the pointer's predecessor", async () => {
|
||||
const kv = makeKV();
|
||||
const token1 = await mod.mintSession(kv, claim(108, "en"));
|
||||
assert.equal(await mod.getTokenForChat(kv, 108), token1);
|
||||
|
||||
let releaseB;
|
||||
const gate = new Promise((resolve) => (releaseB = resolve));
|
||||
const origGet = kv.get.bind(kv);
|
||||
let pointerReads = 1; // token1's mint consumed read #1 of chat:108
|
||||
kv.get = async (key) => {
|
||||
if (key === "chat:108" && (pointerReads += 1) === 3) {
|
||||
// B's read: snapshot the pre-write pointer (token1), then park until
|
||||
// A's entire mint has landed — a replica serving stale data.
|
||||
const stale = await origGet(key);
|
||||
await gate;
|
||||
return stale;
|
||||
}
|
||||
return origGet(key);
|
||||
};
|
||||
|
||||
const promiseA = mod.mintSession(kv, claim(108, "en", 1700000050)); // A: read #2, unparked
|
||||
const promiseB = mod.mintSession(kv, claim(108, "en", 1700000060)); // B: read #3, parked
|
||||
const tokenA = await promiseA; // A's mint lands fully while B is parked
|
||||
releaseB(); // B's stale snapshot resolves — it saw token1, never tokenA
|
||||
const tokenB = await promiseB;
|
||||
|
||||
// last pointer write wins
|
||||
assert.equal(await mod.getTokenForChat(kv, 108), tokenB);
|
||||
// THE HONEST LIMIT: A lost the pointer-write race, so its record is
|
||||
// unreferenced yet live — bounded by the 900 s TTL, nothing more.
|
||||
assert.equal(
|
||||
kv.map.has(`session:${tokenA}`),
|
||||
true,
|
||||
"losers of a pointer-write race survive until TTL (WR-03 corrected claim — NOT structurally closed)",
|
||||
);
|
||||
assert.equal(kv.map.has(`session:${tokenB}`), true, "the pointer-named token is live");
|
||||
|
||||
// next sequential mint sweeps the pointer's predecessor (reconcile at
|
||||
// next use — the shape that keeps the chat converging)
|
||||
const tokenC = await mod.mintSession(kv, claim(108, "en", 1700000070));
|
||||
assert.equal(await mod.getTokenForChat(kv, 108), tokenC);
|
||||
assert.equal(
|
||||
kv.map.has(`session:${tokenB}`),
|
||||
false,
|
||||
"the pointer's predecessor is swept at the next mint",
|
||||
);
|
||||
assert.equal(
|
||||
kv.map.has(`session:${tokenA}`),
|
||||
true,
|
||||
"an unreferenced orphan is invisible to the pointer sweep — TTL (900 s) is the only bound (see sessions.ts header)",
|
||||
);
|
||||
});
|
||||
|
||||
/* ————— SG1/SG2 — claim reads (expired class, D-14) ————— */
|
||||
|
||||
test("SG1 getClaimByToken round-trips language_code 'ms' and null", async () => {
|
||||
|
||||
Reference in New Issue
Block a user