UNPKG

agentfootprint

Version:

The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.

749 lines • 45.6 kB
"use strict"; /** * hosting/conformance/cases — the battery itself. * * Each case holds ONE law of the port and says which one in its `law` field, * so a failure reads as a broken promise rather than as a broken assertion. * Nothing here imports a test framework: a case throws to fail, which is the * one convention every runner in every language already agrees on. */ Object.defineProperty(exports, "__esModule", { value: true }); exports.sessionLifecycleConformance = void 0; const envelope_js_1 = require("../envelope.js"); // ─── The little assertion kit ──────────────────────────────────────── /** Fail this case, naming what was expected. */ function check(condition, message) { if (!condition) throw new Error(message); } /** * The error a call failed with, or `undefined` when it succeeded. * * Takes a THUNK, so a store that throws synchronously where the port promises * a promise is still measured on its semantics rather than blowing the case up * on its calling convention. That style is its own small defect — a caller * then needs two error handlers, and the one they forget is the one that fires * at 3am — but it is not the law any of these cases is about. */ async function attempt(work) { try { await work(); return undefined; } catch (err) { return err; } } /** The text of the conversation inside an envelope, for round-trip checks. */ function textOf(envelope) { const data = envelope?.data; const history = envelope?.format === 'flowchart-v1' ? data.conversation?.history : data.history; if (!Array.isArray(history)) return undefined; const first = history[0]; return typeof first?.content === 'string' ? first.content : undefined; } /** * A stored PAUSE around one of the kit's conversations: still waiting on a * person, or — with `answeredAt` — marked answered, which is what a door * writes when a resume may have used the pause's decision * (`PausedRun.answered`). Its `savedAt` is the conversation's. * * The checkpoint carries only what a reader validates (`checkEnvelope`): the * engine's other halves are not a store's business, which is why the shape is * asserted rather than typed. */ function pausedEnvelope(conversation, answeredAt) { return { format: 'flowchart-v1', savedAt: conversation.savedAt, data: { checkpoint: { pausedStageId: 'ask-a-person', sharedState: {}, subflowPath: [] }, conversation: conversation.data, pending: { question: 'Go ahead?', pauseData: { question: 'Go ahead?' } }, ...(answeredAt !== undefined && { answered: { at: answeredAt } }), }, }; } /** The `answered` mark a stored pause carries, read the way a door reads it. */ function answeredMarkOf(envelope) { return envelope === undefined ? undefined : (0, envelope_js_1.readPausedRun)(envelope).answered?.at; } /** When the conformance cases mark a pause answered. */ const MARKED_AT = 1_700_000_500_000; /** Read every page of a listing, so a paging bug shows up as a wrong SET. */ async function allPages(store, userId, limit) { const ids = []; const pages = []; let cursor; // A bound, not a `while (true)`: a store whose cursor never terminates // should fail this case, not hang the suite that was checking it. for (let page = 0; page < 50; page++) { const result = await store.listByUser(userId, { limit, ...(cursor && { cursor }) }); pages.push(result); for (const row of result.sessions) ids.push(row.sessionId); cursor = result.cursor; if (cursor === undefined) return { ids, pages }; } throw new Error(`listByUser never stopped: 50 pages of ${limit} and the cursor is still present. ` + `A cursor is promised only when MORE rows exist, so a listing that keeps handing one ` + `back is a sidebar that scrolls forever.`); } // ─── The battery ───────────────────────────────────────────────────── const absentHydratesUndefined = { name: 'absent-session-hydrates-undefined', law: 'Only a session that was never written hydrates as `undefined`.', async run(store, kit) { const stored = await store.hydrate(kit.id('never-written')); check(stored === undefined, `hydrate() of a session that was never written answered ${JSON.stringify(stored)}. ` + `An empty store has to say "nothing here" — anything else and the composer starts a ` + `conversation on top of a value it did not understand.`); }, }; const roundTrip = { name: 'persist-hydrate-round-trip', law: 'What persist stored is what hydrate answers, and the last write wins.', async run(store, kit) { const id = kit.id('round-trip'); const first = kit.envelope('turn one', 'alice'); await store.persist(id, first); const back = await store.hydrate(id); check(back !== undefined, 'hydrate() answered undefined for a session that was just written.'); check(back.format === first.format && back.savedAt === first.savedAt, `hydrate() answered format='${String(back.format)}' savedAt=${String(back.savedAt)}, ` + `and '${first.format}'/${first.savedAt} went in. A store that rewrites either one ` + `breaks the reader that branches on format and the listing that sorts on savedAt.`); check(textOf(back) === 'turn one', `the stored conversation came back as ${JSON.stringify(textOf(back))}. ` + `A store may re-encode an envelope; it may not change what was said.`); check((0, envelope_js_1.envelopeOwner)(back) === 'alice', 'the identity the conversation was signed with did not survive the round trip — ' + 'which is the fact every ownership check in this package is derived from.'); // Last write wins for the PAYLOAD. This is the half the ownership rule // deliberately does not touch: turn two replaces turn one. await store.persist(id, kit.envelope('turn two', 'alice')); check(textOf(await store.hydrate(id)) === 'turn two', 'a second persist did not replace the stored conversation. Every turn after the ' + 'first is a second persist, so a store that keeps the first one stops the ' + 'conversation at one exchange.'); }, }; const answeredPauseRoundTrip = { name: 'answered-pause-round-trips', law: 'A stored pause comes back as it went in — still waiting, or with the `answered` mark a door wrote over it.', async run(store, kit) { const id = kit.id('answered'); const conversation = kit.envelope('approve the refund?', 'alice'); // The door's own sequence: the pause is stored, and later marked. await store.persist(id, pausedEnvelope(conversation)); const waiting = await store.hydrate(id); check(waiting?.format === 'flowchart-v1' && answeredMarkOf(waiting) === undefined, `a stored pause came back as format='${String(waiting?.format)}' with answered mark ` + `${JSON.stringify(answeredMarkOf(waiting))}. A pause still waiting on a person has to ` + `come back as one, and with no mark nobody wrote — a mark there hides the question.`); await store.persist(id, pausedEnvelope(conversation, MARKED_AT)); const marked = await store.hydrate(id); check(marked?.format === 'flowchart-v1' && answeredMarkOf(marked) === MARKED_AT, `the \`answered\` mark did not survive the round trip (came back as ` + `${JSON.stringify(answeredMarkOf(marked))}). A door marks a pause answered when a ` + `resume may have used its decision, so the call may have run. A store that drops the ` + `mark offers that request for an answer again — a retry, a restart or another host ` + `would answer it twice.`); }, }; const unreadableIsNotAbsent = { name: 'unreadable-is-not-absent', law: 'A stored conversation this runtime cannot read is never answered as absent.', harnessNeeds: ['corrupt'], async run(store, kit) { const id = kit.id('corrupted'); await store.persist(id, kit.envelope('something real', 'alice')); await kit.harness.corrupt(store, id); // Either answer holds the law. A store that decodes (a file, a document) // refuses BY NAME, so the message points at the store that produced the // bytes; a store that hands its value straight back leaves the refusal to // the composer's readers. What NEITHER may do is answer `undefined` — that // is a fresh start on top of a conversation that exists, and it is // invisible from the outside until somebody's history is gone. let answered; const err = await attempt(async () => { answered = await store.hydrate(id); }); check(err !== undefined || answered !== undefined, `hydrate() answered \`undefined\` for a session whose stored bytes are not a readable ` + `envelope. Absent and unreadable are different facts, and only one of them is safe ` + `to answer with a fresh start.`); }, }; const forgetRemoves = { name: 'forget-removes-the-conversation', law: 'A store that can forget a session forgets it completely, and forgetting twice is not an error.', members: ['forget'], async run(store, kit) { const forget = store.forget.bind(store); const id = kit.id('forgettable'); await store.persist(id, kit.envelope('delete me', 'alice')); await forget(id); check((await store.hydrate(id)) === undefined, 'forget() left the conversation readable. A deletion that does not delete is the ' + 'kind of thing a person finds out about from a regulator.'); if (store.ownerOf !== undefined) { check((await store.ownerOf(id)) === undefined, 'forget() removed the conversation and left the owner index naming somebody. ' + 'A listing built on that index then points at a session nobody can open.'); } // Idempotent: a retry, a double-click and a cleanup job all do this. await forget(id); }, }; const ownershipDerived = { name: 'ownership-is-derived-from-the-envelope', law: 'The owner is DERIVED from the stored conversation, never declared by whoever called persist.', members: ['ownerOf'], async run(store, kit) { const signed = kit.id('signed'); await store.persist(signed, kit.envelope('mine', 'alice')); check((await store.ownerOf(signed)) === 'alice', 'a conversation signed by somebody came back owned by nobody. The owner is the ' + '`principal` on the stored conversation — the composer wrote it there.'); // A caller cannot state an owner, because there is nowhere to state one: // the port takes a session id and an envelope. So the only way to try is // to plant the word somewhere and see whether a store reads it. A store // that does has built its permission system out of a request field. const decoy = kit.id('decoy'); const envelope = kit.envelope('not mine', 'alice'); await store.persist(decoy, { ...envelope, // Two shapes a hopeful caller might try, neither of which is where the // identity lives. `unknown` first because the envelope union has no such // fields — which is the point: they are not part of the contract, and a // store that reads one has invented an API nobody offered. owner: 'mallory', userId: 'mallory', }); const owner = await store.ownerOf(decoy); check(owner === 'alice', `a planted 'owner' field on the envelope was read as the owner (got ` + `${JSON.stringify(owner)}). Owning somebody's session must not be a matter of ` + `asking for it.`); }, }; const ownershipFillsIn = { name: 'ownership-fills-in-on-a-later-signed-turn', law: 'A conversation that ran anonymously and is then signed for gains that owner.', members: ['ownerOf'], async run(store, kit) { const id = kit.id('gains-an-owner'); await store.persist(id, kit.envelope('who am i')); check((await store.ownerOf(id)) === undefined, 'an anonymous conversation came back owned by somebody. Inventing an owner is ' + 'inventing an entitlement.'); await store.persist(id, kit.envelope('now signed in', 'alice')); check((await store.ownerOf(id)) === 'alice', 'the FIRST turn that signs for a conversation owns it, and this one did not. ' + 'A session that gained an identity on turn two must appear in that person’s list.'); }, }; const leanerTurnKeepsOwner = { name: 'ownership-survives-a-leaner-turn', law: 'A later turn carrying a LEANER identity is stored, and does not erase the owner.', members: ['ownerOf'], async run(store, kit) { const id = kit.id('leaner-turn'); await store.persist(id, kit.envelope('turn one', 'alice')); // The blessed flow, and the reason this suite refuses an absence rather // than treating it as a conflict: a turn with no identity claims NOBODY, // so it contradicts nobody. Refusing it would break a flow the contract // describes as intended, to protect against a claim that was never made. const err = await attempt(() => store.persist(id, kit.envelope('turn two'))); check(err === undefined, `persisting a turn with no identity onto an owned session was REFUSED (${String(err)}). ` + `An absent identity is not a contradiction, and the port blesses this write in as ` + `many words. A store that refuses it fails turns that have always worked.`); check((await store.ownerOf(id)) === 'alice', 'a leaner turn erased the owner. The session would silently drop out of its ' + "owner's list, which looks to them exactly like losing the conversation."); check(textOf(await store.hydrate(id)) === 'turn two', 'the leaner turn was accepted and then not stored. Keeping the owner is not a ' + 'reason to drop the conversation.'); if (store.listByUser !== undefined) { const { ids } = await allPages(store, 'alice', 10); check(ids.includes(id), 'the owner kept the session and the listing lost it. An index and a listing that ' + 'disagree is a sidebar missing a conversation nobody can explain.'); } }, }; const differentSignerRefused = { name: 'ownership-is-not-taken-by-a-different-signer', law: 'A turn signed by somebody else is refused WHOLE — the owner index and the stored conversation both.', members: ['ownerOf'], async run(store, kit) { const id = kit.id('contested'); await store.persist(id, kit.envelope('alice speaking', 'alice')); const err = await attempt(() => store.persist(id, kit.envelope('bob speaking', 'bob'))); check(err !== undefined, `persisting a conversation signed by a DIFFERENT person onto an owned session was ` + `accepted. This is the defect the suite was written for: the write-once rule keeps ` + `the first writer in the owner index and stores the second writer's entire ` + `conversation, so the owner lists the session, opens it, and reads somebody else's.`); // A refusal is read by whoever provoked it. It must teach, and it must // not be a way to find out who is signed in or what they said. const text = err instanceof Error ? `${err.name}: ${err.message}` : String(err); for (const leak of ['alice', 'bob', 'alice speaking', 'bob speaking']) { check(!text.toLowerCase().includes(leak), `the refusal names '${leak}'. An ownership refusal must carry no principal, no ` + `owner and no conversation text — otherwise provoking one is how you find out ` + `who owns a session id.`); } // And the half that matters: refused means NOTHING moved. check((await store.ownerOf(id)) === 'alice', 'the refusal fired and the owner changed anyway.'); const back = await store.hydrate(id); check(textOf(back) === 'alice speaking' && (0, envelope_js_1.envelopeOwner)(back) === 'alice', `the refusal fired and the CONVERSATION was replaced anyway (stored: ` + `${JSON.stringify(textOf(back))} signed by ${JSON.stringify((0, envelope_js_1.envelopeOwner)(back))}). ` + `Refusing only the index column is the original defect wearing a thrown error.`); }, }; const contestedWrite = { name: 'contested-write-leaves-no-split-brain', law: 'However two concurrent writers interleave, the owner index and the stored conversation never name different people.', members: ['ownerOf'], async run(store, kit) { const id = kit.id('race'); // Both writers, on one FRESH session, started without awaiting the first — // the shape a live trial reproduced against a real backend, where both // calls fulfilled and the second was genuinely retried. const settled = await Promise.all([ attempt(() => store.persist(id, kit.envelope('alice speaking', 'alice'))), attempt(() => store.persist(id, kit.envelope('bob speaking', 'bob'))), ]); if (!settled.some((err) => err === undefined)) { // Both were rejected — but `attempt` catches EVERY rejection, and a store // that cannot be reached rejects both writers too. A locked database // account, a closed pool and a refused ownership write are three different // facts, and this case used to report all of them as the same verdict. // // Found in the field: a trial whose SQL login was locked out mid-run got // "both writers were refused, so nothing was stored at all" — an ownership // finding about a database the battery never reached. So the case now // earns the right to that verdict by proving the store answers at all. // Same distinction `unreadable-is-not-absent` exists for, one level up: // "it refused" and "it could not be asked" must not collapse into one. const control = await attempt(() => store.persist(kit.id('reachable'), kit.envelope('an uncontested write', 'alice'))); check(control === undefined, `this store rejected BOTH contested writers and an uncontested one as well, so it ` + `could not be reached — a bad credential, a closed pool, a network error. That is ` + `an environment failure and NOT a verdict about contested writes: fix the ` + `connection and run again. The uncontested write said: ${String(control)}`); check(false, `both writers were refused, so nothing was stored at all — and an uncontested write ` + `to this same store SUCCEEDED, so the store is reachable and this is really about ` + `contention. One of them was first and is entitled to its conversation; refusing ` + `both turns a contested write into an outage.`); } const owner = await store.ownerOf(id); const signer = (0, envelope_js_1.envelopeOwner)(await store.hydrate(id)); // The invariant, one-directional on purpose. An index that names somebody // over a conversation that names nobody is the leaner-turn state, and it // only ever refuses. An index and a conversation naming DIFFERENT people // is the split brain: the index decides who may list and read it, the // conversation decides what they read, and they disagree. check(owner === undefined || signer === undefined || owner === signer, `SPLIT BRAIN: ownerOf() and the stored conversation name different people. ` + `The person the index names lists this session, opens it, and reads the other ` + `one's conversation.`); // And the conversation that survived is a WHOLE one. A store that let both // writes land field by field would satisfy the check above by accident // while holding a document nobody wrote. check(['alice speaking', 'bob speaking'].includes(textOf(await store.hydrate(id)) ?? ''), 'the stored conversation is neither writer’s. A contested write may lose one turn; ' + 'it may not invent a third.'); }, }; const ownerOfAmbiguity = { name: 'owner-of-is-undefined-for-missing-and-unowned', law: '`ownerOf` answers `undefined` for BOTH a session that does not exist and one nobody signed for.', members: ['ownerOf'], async run(store, kit) { const missing = await store.ownerOf(kit.id('no-such-session')); check(missing === undefined, `ownerOf() of a session that does not exist answered ${JSON.stringify(missing)}.`); const unowned = kit.id('unowned'); await store.persist(unowned, kit.envelope('nobody signed this')); const owner = await store.ownerOf(unowned); check(owner === undefined, `ownerOf() of a session nobody signed for answered ${JSON.stringify(owner)}. ` + `The two answers must be indistinguishable, or a caller has an oracle for which ` + `session ids are real.`); }, }; const listPages = { name: 'list-by-user-pages-with-a-stable-tie-break', law: 'Paging a listing shows every owned session exactly once, nobody else’s ever, and hands back a cursor only when more rows exist.', members: ['listByUser'], async run(store, kit) { // Five for Alice sharing ONE savedAt, so the tie-break is what carries the // paging rather than the timestamp. A cursor that only orders by time // repeats or skips a row exactly here, and only here. const tied = 1_700_000_000_000; const alices = []; for (let n = 0; n < 5; n++) { const id = kit.id(`tied-${n}`); alices.push(id); await store.persist(id, kit.envelope(`alice ${n}`, 'alice', tied)); } const bob = kit.id('bobs'); await store.persist(bob, kit.envelope('bob', 'bob', tied)); const { ids, pages } = await allPages(store, 'alice', 2); check(!ids.includes(bob), "another user's session appeared in this listing. A listing IS the permission " + 'check for everything downstream of it.'); check(new Set(ids).size === ids.length, `paging repeated a session (${ids.join(', ')}). With equal timestamps a cursor needs ` + `a second, total ordering — usually the session id — or a row lands on both sides ` + `of a page boundary.`); check(alices.every((id) => ids.includes(id)), `paging LOST a session. Seen: ${ids.join(', ')}. Written: ${alices.join(', ')}.`); const last = pages[pages.length - 1]; check(last?.cursor === undefined, 'the final page still carried a cursor. A cursor promises MORE, so one on the last ' + 'page is a sidebar with a "load more" that loads nothing.'); for (const page of pages.slice(0, -1)) { check(page.cursor !== undefined, 'a non-final page carried no cursor, so the rows after it are unreachable.'); } }, }; /** * Split out of the paging case deliberately. * * That case holds three separate laws — nobody else's rows, complete paging * with an honest cursor, and newest-first order — and one store can hold two of * them while genuinely being unable to hold the third. Declaring a case is * all-or-nothing, so a combined case forces a store with ONE real ceiling to * forfeit checks it passes, including the cross-user leak check, which is the * single most valuable assertion in the battery. One case, one law. */ const listNewestFirst = { name: 'list-by-user-is-newest-first', law: 'A listing is ordered newest first, by the conversation’s own timestamp rather than by the order rows happened to be written.', members: ['listByUser'], async run(store, kit) { const tied = 1_700_000_000_000; // Checked TWICE, with the two confounders inverted between the rounds. // // One round cannot hold this law. Ids from `kit.id` count up and writes are // ordered, so in any single arrangement the newest row is ALSO the // later-written row and the lexicographically larger id — three signals // pointing the same way. A store that never reads the timestamp and simply // sorts by insertion order, or by id, then lands on the right answer for // the wrong reason. Inverting both between the rounds means any such store // gets exactly one of them backwards. const rounds = [ { user: 'carol', newestIsWrittenFirst: true }, { user: 'dave', newestIsWrittenFirst: false }, ]; for (const { user, newestIsWrittenFirst } of rounds) { const written = [kit.id('ordered-a'), kit.id('ordered-b')]; const newest = newestIsWrittenFirst ? written[0] : written[1]; for (const id of written) { const age = id === newest ? tied + 60_000 : tied - 60_000; await store.persist(id, kit.envelope(id === newest ? 'newer' : 'older', user, age)); } const listed = await store.listByUser(user, { limit: 10 }); check(listed.sessions[0]?.sessionId === newest, `the listing did not put the newest conversation first (got ` + `${JSON.stringify(listed.sessions.map((s) => s.sessionId))}, newest is ` + `${JSON.stringify(newest)}, which was written ` + `${newestIsWrittenFirst ? 'FIRST and has the smaller id' : 'LAST and has the larger id'}). ` + `Newest-first is the order a sidebar draws in — and it has to come from the ` + `conversation's timestamp, not from the order rows happened to be inserted in. ` + `A store whose backend orders by its own write time cannot hold this and should ` + `DECLARE it rather than appear to pass.`); } }, }; const listAnsweredAsConversation = { name: 'list-by-user-shows-an-answered-pause-as-a-conversation', law: 'A listing reports `flowchart-v1` only for a pause still waiting on an answer; an answered pause lists as `conversation-v1`, like a finished turn.', members: ['listByUser'], async run(store, kit) { const at = 1_700_000_000_000; const waiting = kit.id('still-waiting'); const answered = kit.id('answered'); const finished = kit.id('finished'); await store.persist(waiting, pausedEnvelope(kit.envelope('still waiting', 'alice', at))); // Stored as a pause first, then marked — so a store that writes the // listed format when it persists has to write it again. await store.persist(answered, pausedEnvelope(kit.envelope('answered', 'alice', at + 1))); await store.persist(answered, pausedEnvelope(kit.envelope('answered', 'alice', at + 2), MARKED_AT)); await store.persist(finished, kit.envelope('finished', 'alice', at + 3)); const { pages } = await allPages(store, 'alice', 10); const listed = new Map(pages.flatMap((page) => page.sessions).map((row) => [row.sessionId, row.format])); check(listed.get(waiting) === 'flowchart-v1', `a pause still waiting on a person was listed as ` + `${JSON.stringify(listed.get(waiting))}. 'flowchart-v1' is what a sidebar badges ` + `"awaiting you" — the one row it must not miss.`); check(listed.get(answered) === 'conversation-v1', `an ANSWERED pause was listed as ${JSON.stringify(listed.get(answered))}. A resume ` + `already used its decision and no door offers it for an answer again, so a sidebar ` + `that badges it "awaiting you" shows a question nobody can answer. List it as ` + `'conversation-v1' — \`listedFormatOf(envelope)\` from 'agentfootprint/hosting' is the ` + `one reading, at persist for a store that keeps the format in a column (and again at ` + `list time for a row that still says 'flowchart-v1'), else at list time.`); check(listed.get(finished) === 'conversation-v1', `a finished conversation was listed as ${JSON.stringify(listed.get(finished))}.`); }, }; /** Long enough to cross any plausible key-length ceiling. */ const PAST_ANY_CEILING = 1000; const FOLD_PAIRS = [ // ── characters rewritten or decoded ── { fold: 'percent-decoding', left: (b) => `${b}a/b`, right: (b) => `${b}a%2Fb` }, { fold: 'every illegal character replaced by one filler', left: (b) => `${b}a/b`, right: (b) => `${b}a-b`, }, { fold: 'a different illegal character, the same filler', left: (b) => `${b}a:b`, right: (b) => `${b}a-b`, }, { fold: 'underscore and dash treated as the same character', left: (b) => `${b}a_b`, right: (b) => `${b}a-b`, }, // ── case and Unicode ── // An id is bytes somebody chose. macOS stores NFD, a database column may // collate NFC, and two ids a person reads as identical swap places in // between — the classic silent id collision, and one no ASCII pair can see. { fold: 'case folding', left: (b) => `${b}AB`, right: (b) => `${b}ab` }, { fold: 'Unicode normalisation (NFC against NFD)', left: (b) => `${b}café`, right: (b) => `${b}café`, }, { fold: 'case folding applied outside ASCII', left: (b) => `${b}ÜSER`, right: (b) => `${b}üSER`, }, { fold: 'compatibility normalisation (NFKC)', left: (b) => `${b}ab`, right: (b) => `${b}ab`, }, { fold: 'zero-width characters stripped', left: (b) => `${b}chat\u200B1`, right: (b) => `${b}chat1`, }, // ── position: which END of an id a store keeps ── { fold: 'truncation at a length ceiling', left: (b) => `${b}${'x'.repeat(PAST_ANY_CEILING)}A`, right: (b) => `${b}${'x'.repeat(PAST_ANY_CEILING)}B`, }, // Both of the next two are PADDED past any window a store might keep. A pair // that differs near one end is invisible to a store that keeps the other end, // so the difference and the padding have to sit on opposite sides — without // the padding these two read correctly and catch nothing. { fold: 'anything that discards the HEAD, such as keying on a last path segment', left: (b) => `alice/${b}${'p'.repeat(PAST_ANY_CEILING)}`, right: (b) => `bob/${b}${'p'.repeat(PAST_ANY_CEILING)}`, }, { fold: 'a stripped leading prefix', left: (b) => `session-${b}`, right: (b) => b }, { fold: 'truncation from the FRONT', left: (b) => `A${'q'.repeat(PAST_ANY_CEILING)}${b}`, right: (b) => `B${'q'.repeat(PAST_ANY_CEILING)}${b}`, }, ]; /** * The generic form of a collision a fold-pair table cannot express. * * A pair like `a_b` / `a-b` catches a store that folds punctuation. It cannot * catch a store whose mapping is IDEMPOTENT — one that rewrites an awkward id * into some composed form and then passes that composed form through unchanged. * There `x` and `f(x)` are two different ids addressing one conversation, and * the second is not something a table can guess, because only the store knows * what `f` is. * * So this case does not guess: it asks the store for an id, by reading the one * its own listing hands back, and then tries to use that as a second address. * A caller doing exactly this is the ordinary case, not an attack — a sidebar * lists conversations and opens the one that was clicked. */ const handedBackIdIsNotASecondAddress = { name: 'an-id-the-store-hands-back-is-not-a-second-address', law: 'An id a store reports in a listing addresses THAT conversation and no other — it is never a second name for a different one.', members: ['listByUser'], async run(store, kit) { const chosen = `${kit.id('handed-back')}A_b.c`; await store.persist(chosen, kit.envelope('the original conversation', 'alice')); const listed = await store.listByUser('alice', { limit: 50 }); const reported = listed.sessions.find((row) => row.sessionId === chosen)?.sessionId ?? listed.sessions[listed.sessions.length - 1]?.sessionId; check(reported !== undefined, 'listByUser did not report a conversation that was just written for that user.'); // The reported id must round-trip: a listing a caller cannot open is not a // listing. This is the half stores usually get right. check(textOf(await store.hydrate(reported)) === 'the original conversation', `listByUser reported ${JSON.stringify(reported)} and hydrate() of that id did not ` + `return the conversation it was reported for. A sidebar lists conversations and ` + `opens the one that was clicked, so an id in a listing has to be openable.`); // And it must not ALSO be a way to address something else. If the store // rewrote the caller's id into `reported`, and `reported` maps to itself, // then writing here lands on the first conversation. if (reported !== chosen) { await store.persist(reported, kit.envelope('a different conversation', 'alice')); check(textOf(await store.hydrate(chosen)) === 'the original conversation', `writing to the id this store REPORTED (${JSON.stringify(reported)}) overwrote the ` + `conversation stored under the id the caller CHOSE (${JSON.stringify(chosen)}). The ` + `store rewrote one into the other and then accepted the rewritten form as an id in ` + `its own right, so two different session ids now name one conversation — and the ` + `second one is a value this store published.`); } }, }; const awkwardIds = { name: 'awkward-session-ids-round-trip', law: 'A session id is OPAQUE: whatever a caller chose comes back as its own conversation, and two different ids never collide.', async run(store, kit) { const awkward = [ 'has/slashes/inside', 'has spaces', '..', './..', 'ünïcødé-😀-id', `quote'and"double`, "'; DROP TABLE agent_sessions; --", '\u0000-ish-but-not-really', 'a'.repeat(1000), ]; // A store may REFUSE an id it cannot hold faithfully — a column has a width, // and refusing beats truncating, which is exactly how two ids become one. So // the law is not "every id round-trips"; it is "every id the store ACCEPTED // comes back as its own conversation". A write that never happened collides // with nothing, and a store with an honest ceiling should not have to choose // between declaring this whole case and looking non-conformant. const accepted = async (id, text) => (await attempt(() => store.persist(id, kit.envelope(text, 'alice')))) === undefined; for (const [index, raw] of awkward.entries()) { const id = `${kit.id('awkward')}${raw}`; if (!(await accepted(id, `conversation ${index}`))) continue; const back = await store.hydrate(id); check(textOf(back) === `conversation ${index}`, `a session id the caller chose did not round-trip: ${JSON.stringify(raw)} came back ` + `as ${JSON.stringify(textOf(back))}. An id is somebody's opaque string, and a ` + `store that mangles it hands them a different conversation.`); } for (const { fold, left, right } of FOLD_PAIRS) { const base = kit.id('fold'); const [leftId, rightId] = [left(base), right(base)]; const stored = [ await accepted(leftId, `left ${fold}`), await accepted(rightId, `right ${fold}`), ]; // Only the ids this store took are held to the law. If it refused one, // there is no second write to collide with the first. check((!stored[0] || textOf(await store.hydrate(leftId)) === `left ${fold}`) && (!stored[1] || textOf(await store.hydrate(rightId)) === `right ${fold}`), `two different session ids collided onto one conversation — ${fold}. The two ids ` + `differ only where that fold applies, and after this store mapped them they were ` + `the same key. Whatever mapping a store uses to make an id legal for its backend ` + `has to be injective, or one person’s turn lands in another’s conversation.`); } }, }; const retentionHonesty = { name: 'retention-says-who-deletes-and-deletes-only-the-old', law: 'A store that answers `retention()` says WHO deletes — and where that is the store itself, it forgets exactly the sessions older than the cutoff and nothing else.', members: ['retention'], async run(store, kit) { const policy = store.retention(); // The BACKEND arm. There is nothing to call, so what is checked is the // only thing that can be wrong: whether an operator could act on it. A // policy that names no field and no step is a store saying "somebody else // handles it" — which is what every deployment believes right up until // somebody asks how long conversations are kept. if (policy.deletedBy === 'the-backend') { check(typeof policy.expiresOn === 'string' && policy.expiresOn.length > 0, 'a backend-enforced retention policy named no field or setting for the backend to ' + 'act on. An operator configuring the policy has to know what it reads.'); check(typeof policy.enableWith === 'string' && policy.enableWith.length > 30, 'a backend-enforced retention policy carries no step an operator can take. The ' + 'store knows its own collection, database or resource; making somebody go and ' + 'look that up is how retention stays switched off.'); check(typeof policy.active === 'boolean', '`active` is not a boolean. "There is an expiry mechanism" and "conversations are ' + 'expiring" are different facts, and this is the one that says which.'); return; } // The SWEEP arm. Three sessions around one cutoff, so the boundary is // under test rather than assumed: strictly-before is what makes the same // cutoff safe to pass twice. const cutoff = 1_700_000_000_000; const old = kit.id('older-than-the-cutoff'); const boundary = kit.id('exactly-at-the-cutoff'); const fresh = kit.id('newer-than-the-cutoff'); await store.persist(old, kit.envelope('last year', 'alice', cutoff - 60_000)); await store.persist(boundary, kit.envelope('right on it', 'alice', cutoff)); await store.persist(fresh, kit.envelope('this morning', 'alice', cutoff + 60_000)); const swept = await policy.forgetOlderThan(cutoff); check(swept.forgotten === 1, `the sweep reported ${swept.forgotten} conversations forgotten and exactly one was ` + `older than the cutoff. A count a cleanup job cannot trust is a count it will log ` + `and nobody will read.`); check(swept.more === false, 'the sweep says more sessions remain older than the cutoff, and none do. `more` is ' + 'what a caller loops on, so a store that always says `true` is an infinite loop.'); check((await store.hydrate(old)) === undefined, 'the sweep reported forgetting a conversation and it is still readable. A deletion ' + 'that does not delete is the kind of thing a person finds out about from a regulator.'); check(textOf(await store.hydrate(boundary)) === 'right on it', 'the sweep deleted the session saved EXACTLY at the cutoff. The bound is strictly ' + 'before, so that passing one cutoff twice is stable and a conversation on the ' + 'boundary survives — an off-by-one here deletes somebody who is still talking.'); check(textOf(await store.hydrate(fresh)) === 'this morning', 'the sweep deleted a conversation NEWER than the cutoff. Whatever else a retention ' + 'job gets wrong, it may not delete the conversation somebody is having.'); // The index has to go with the conversation, exactly as `forget` must — // a listing built on an index the sweep did not touch points at // conversations nobody can open. if (store.ownerOf !== undefined) { check((await store.ownerOf(old)) === undefined, 'the sweep forgot the conversation and left the owner index naming somebody for it.'); } if (store.listByUser !== undefined) { const { ids } = await allPages(store, 'alice', 10); check(!ids.includes(old) && ids.includes(fresh), `after the sweep the listing is ${ids.join(', ')} — it must have lost the swept ` + `session and kept the rest. A sidebar row that opens nothing is worse than a row ` + `that is gone.`); } // Idempotent, and bounded. A cleanup job runs on a schedule; most of its // runs have nothing to do, and "nothing to do" is not an error. const again = await policy.forgetOlderThan(cutoff); check(again.forgotten === 0 && again.more === false, `sweeping the same cutoff twice forgot ${again.forgotten} more conversations. The ` + `second run of a nightly job must be a no-op, or the job is deleting on a moving ` + `target.`); const limited = await policy.forgetOlderThan(cutoff + 120_000, { limit: 1 }); check(limited.forgotten === 1 && limited.more === true, `a sweep limited to one conversation reported ${limited.forgotten} forgotten and ` + `more=${String(limited.more)}, with two eligible. The limit is what keeps the first ` + `sweep of a neglected store from becoming one enormous transaction, and \`more\` is ` + `how a caller knows to come back.`); }, }; const featureDetected = { name: 'optional-members-are-feature-detected', law: 'The optional members are present as functions or absent — never something a caller has to guess about.', async run(store) { for (const member of ['onWake', 'listByUser', 'ownerOf', 'retention']) { const value = store[member]; check(value === undefined || typeof value === 'function', `'${member}' is present as ${typeof value}. Feature detection reads ` + `\`typeof store.${member} === 'function'\`, so a member that is present-but-not-a-` + `function is detected as available and then fails at the door.`); } check(store.listByUser === undefined || store.ownerOf !== undefined, 'this store can LIST a user’s sessions and cannot check ownership of one. ' + 'A door built on it hands somebody a list and then refuses to open any of it — ' + 'implement `ownerOf` beside `listByUser`.'); // Retention is DESCRIBED synchronously — the answer is configuration, not // a round trip — and a store that made a caller await a description would // make an incident-time question ("what expires these?") cost a network // call on a store that may already be closed. if (store.retention !== undefined) { const policy = store.retention(); const deletedBy = policy?.deletedBy; check(deletedBy === 'this-store' || deletedBy === 'the-backend', `retention() answered ${JSON.stringify(deletedBy)} for \`deletedBy\`. That field is ` + `the discriminant a caller branches on — one arm hands you something to CALL and ` + `the other something to CONFIGURE — so a third value is a branch nobody wrote.`); if (deletedBy === 'this-store') { check(typeof policy.forgetOlderThan === 'function', 'retention() says this store does the deleting and gave nothing to call. ' + 'The sweep IS the arm — without it the answer is a claim, not a mechanism.'); } } }, }; /** * The battery, in the order a store fails it most usefully: reading before * writing, writing before ownership, ownership before the listing built on it, * and the listing before the retention that deletes out from under it. */ exports.sessionLifecycleConformance = [ absentHydratesUndefined, roundTrip, answeredPauseRoundTrip, unreadableIsNotAbsent, forgetRemoves, ownershipDerived, ownershipFillsIn, leanerTurnKeepsOwner, differentSignerRefused, contestedWrite, ownerOfAmbiguity, listPages, listNewestFirst, listAnsweredAsConversation, handedBackIdIsNotASecondAddress, awkwardIds, retentionHonesty, featureDetected, ]; //# sourceMappingURL=cases.js.map