From 1d5c27ac962c73df05ada01224a6c71d36335c26 Mon Sep 17 00:00:00 2001 From: CCC Date: Thu, 17 Sep 2026 17:44:09 +0800 Subject: [PATCH] quilt kernel P2: the encounter machine's shadow, with its negative ledger MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The encounter machine gates inserts on changes()=1 across a quadruple NOT EXISTS guard (actor/target/channel/already-exists). Most attempts in the wild are refusals — and refusals leave no rows in live D1. The negative ledger makes a refusal a first-class WAL row: encounter..refused = { refusedBy, remaining, ts } analyzeRefusals() walks the WAL and returns every denied attempt — a query that is impossible against live D1, since the absences are the information. H(N) > H(R), operationalized. Also: - generalized commitProjection(); nomination projection stays intact - link/unlink events now persist as edge rows (from->to) so the cell graph survives the WAL; replay ignores non-bind ops - dual-write wired into createLobsterEncounter / bindLobsterMessage / markLobsterPublicationFailed / recordLobsterResponse; projection failures log-and-never-throw (P1 posture) - tests/quiltKernelEncounters.test.ts: 6 tests — created-replay match, refusal ledger per guard dimension, retry row, bind->response lifecycle, publication failure, chain integrity across mixed traffic Full suite: 383 tests, 4 pre-existing artwork/env failures (identical on clean main), zero regressions. typecheck clean. Refs #48 --- src/data/lobsterEncounters.ts | 189 ++++++++++++---- src/quilt/commit.ts | 62 +++++- src/quilt/projection.ts | 238 +++++++++++++++++++- tests/quiltKernelEncounters.test.ts | 325 ++++++++++++++++++++++++++++ tests/quiltKernelReplay.test.ts | 4 +- 5 files changed, 759 insertions(+), 59 deletions(-) create mode 100644 tests/quiltKernelEncounters.test.ts diff --git a/src/data/lobsterEncounters.ts b/src/data/lobsterEncounters.ts index 29356ea..afe7cc6 100644 --- a/src/data/lobsterEncounters.ts +++ b/src/data/lobsterEncounters.ts @@ -4,6 +4,13 @@ import { lobsterEncounters, type LobsterEncounter } from "../db/schema.js" +import { commitProjection } from "../quilt/commit.js" +import { + projectLobsterEncounter, + projectLobsterPublication, + projectLobsterResponse, + type EncounterProjectionInput +} from "../quilt/projection.js" import { actionCooldownExpiries, readActionCooldowns, @@ -87,6 +94,60 @@ const toJson = (value: unknown) => { return serialized } +// Dual-write the outcome into the quilt kernel's WAL. Failure is logged, +// never thrown — the projection is a mirror (P2 posture, same as P1). +// The negative ledger lives here: cooldown refusals get first-class rows. +const projectEncounterSafely = async ( + database: LobsterDatabase, + input: CreateLobsterEncounterInput, + outcome: CreateLobsterEncounterResult, + ts: string +): Promise => { + try { + const attempt = { + guildId: input.guildId, + channelId: input.channelId, + actorId: input.actorId, + targetId: input.targetId + } + const projection: EncounterProjectionInput = + outcome.kind === "cooldown" + ? { + resultKind: "cooldown", + interactionId: input.interactionId, + attempt, + refusedBy: outcome.cooldowns.map((cooldown) => cooldown.kind), + remaining: outcome.cooldowns, + ts + } + : outcome.kind === "created" + ? { + resultKind: "created", + interactionId: input.interactionId, + attempt, + encounter: { + id: outcome.encounter.id, + speciesDisplayName: outcome.encounter.speciesDisplayName, + publicationStatus: outcome.encounter.publicationStatus + }, + ts + } + : { + resultKind: outcome.kind, + interactionId: input.interactionId, + attempt, + ts + } + await commitProjection( + database.$client, + { mutationId: input.interactionId, ts }, + (kernel) => projectLobsterEncounter(kernel, projection) + ) + } catch (error) { + console.warn("quilt wal encounter projection failed", error) + } +} + export const getLobsterEncounter = async ( id: number, database: LobsterDatabase = getPrimaryDb() @@ -334,42 +395,45 @@ export const createLobsterEncounter = async ( ]) const existingId = Number(results[0]?.results[0]?.id) + let outcome: CreateLobsterEncounterResult if (Number.isInteger(existingId) && existingId > 0) { const encounter = await getLobsterEncounter(existingId, database) if (!encounter) { throw new Error(`Lobster encounter ${existingId} disappeared during retry`) } - return existingResult(encounter) - } - - const createdId = Number(results[3]?.results[0]?.id) - if (Number.isInteger(createdId) && createdId > 0) { - const encounter = await getLobsterEncounter(createdId, database) - if (!encounter) { - throw new Error(`Lobster encounter ${createdId} disappeared after creation`) + outcome = existingResult(encounter) + } else { + const createdId = Number(results[3]?.results[0]?.id) + if (Number.isInteger(createdId) && createdId > 0) { + const encounter = await getLobsterEncounter(createdId, database) + if (!encounter) { + throw new Error(`Lobster encounter ${createdId} disappeared after creation`) + } + outcome = { kind: "created", encounter } + } else { + const concurrentEncounter = await getLobsterEncounterByInteractionId( + input.interactionId, + database + ) + if (concurrentEncounter) { + outcome = existingResult(concurrentEncounter) + } else { + const cooldowns = readActionCooldowns([ + { kind: "actor", expiresAt: results[4]?.results[0]?.actor_expires_at }, + { kind: "target", expiresAt: results[5]?.results[0]?.target_expires_at }, + { kind: "channel", expiresAt: results[6]?.results[0]?.channel_expires_at } + ], referenceDate) + if (cooldowns.length === 0) { + throw new Error( + "Lobster encounter was neither created nor blocked by a cooldown" + ) + } + outcome = { kind: "cooldown", cooldowns } + } } - return { kind: "created", encounter } - } - - const concurrentEncounter = await getLobsterEncounterByInteractionId( - input.interactionId, - database - ) - if (concurrentEncounter) { - return existingResult(concurrentEncounter) } - - const cooldowns = readActionCooldowns([ - { kind: "actor", expiresAt: results[4]?.results[0]?.actor_expires_at }, - { kind: "target", expiresAt: results[5]?.results[0]?.target_expires_at }, - { kind: "channel", expiresAt: results[6]?.results[0]?.channel_expires_at } - ], referenceDate) - if (cooldowns.length === 0) { - throw new Error( - "Lobster encounter was neither created nor blocked by a cooldown" - ) - } - return { kind: "cooldown", cooldowns } + await projectEncounterSafely(database, input, outcome, timestamp) + return outcome } export const bindLobsterMessage = async ( @@ -434,15 +498,30 @@ export const bindLobsterMessage = async ( ) { return { kind: "conflict", encounter } } - return { - kind: - previous.message_id === messageId - ? "already_bound" - : results[1]?.results[0] - ? "bound" - : "conflict", - encounter + const kind = + previous.message_id === messageId + ? "already_bound" + : results[1]?.results[0] + ? "bound" + : "conflict" + if (kind === "bound") { + try { + await commitProjection( + database.$client, + { mutationId: `bind:${encounterId}:${messageId}`, ts: timestamp }, + (kernel) => + projectLobsterPublication(kernel, { + encounterId, + kind: "bound", + messageId, + ts: timestamp + }) + ) + } catch (error) { + console.warn("quilt wal bind projection failed", error) + } } + return { kind, encounter } } export const markLobsterPublicationFailed = async ( @@ -489,6 +568,21 @@ export const markLobsterPublicationFailed = async ( return { kind: "not_found" } } if (results[0]?.results[0]) { + try { + await commitProjection( + database.$client, + { mutationId: `pubfail:${encounter.interactionId}`, ts: timestamp }, + (kernel) => + projectLobsterPublication(kernel, { + encounterId, + kind: "publication_failed", + failure, + ts: timestamp + }) + ) + } catch (error) { + console.warn("quilt wal publication projection failed", error) + } return { kind: "marked_failed", encounter } } return { @@ -578,8 +672,23 @@ export const recordLobsterResponse = async ( if (!authorized) { return { kind: "unauthorized", encounter } } - return { - kind: updateResult?.results[0] ? "recorded" : "already_recorded", - encounter + const kind = updateResult?.results[0] ? "recorded" : "already_recorded" + if (kind === "recorded") { + try { + await commitProjection( + database.$client, + { mutationId: `response:${encounter.interactionId}`, ts: timestamp }, + (kernel) => + projectLobsterResponse(kernel, { + encounterId: input.encounterId, + responseType: input.responseType, + responderId: input.responderId, + ts: timestamp + }) + ) + } catch (error) { + console.warn("quilt wal response projection failed", error) + } } + return { kind, encounter } } diff --git a/src/quilt/commit.ts b/src/quilt/commit.ts index 42c2ad4..311c1c9 100644 --- a/src/quilt/commit.ts +++ b/src/quilt/commit.ts @@ -1,13 +1,11 @@ -// commit.ts — dual-write one vote transition into the quilt WAL. +// commit.ts — dual-write state transitions into the quilt WAL. import { QuiltKernel, type QuiltEvent } from "./reference-kernel.mjs" import { buildWalRows, - projectNominationVote, - type VoteProjectionInput, type WalRow } from "./projection.js" -type WalClient = { +export type WalClient = { batch>( statements: unknown[] ): Promise> @@ -18,19 +16,24 @@ type WalClient = { export type ChainTip = { tip: number; prev: string | null } -// Project a vote into kernel ops and append the hash-chained WAL rows in -// ONE batch. Returns the number of rows committed. Throws only on D1 -// failure — callers in the vote path catch and log (dual-write posture). -export const commitNominationVoteProjection = async ( +export type CommitContext = { + mutationId: string + ts: string +} + +// Generic projector: run `project` against a fresh kernel, drain the +// events, append hash-chained WAL rows in ONE batch. Returns rows written. +export const commitProjection = async ( client: WalClient, - input: VoteProjectionInput + context: CommitContext, + project: (kernel: QuiltKernel) => void ): Promise => { const kernel = new QuiltKernel() const events: QuiltEvent[] = [] const unsubscribe = kernel.subscribe((event) => { events.push(event) }) - projectNominationVote(kernel, input) + project(kernel) unsubscribe() const [tipResult] = await client.batch([ @@ -42,8 +45,8 @@ export const commitNominationVoteProjection = async ( ]) const tipRow = tipResult?.results?.[0] ?? { tip: 0, prev: null } const rows = buildWalRows(events, { - mutationId: input.mutationId, - ts: input.ts, + mutationId: context.mutationId, + ts: context.ts, tip: Number(tipRow.tip ?? 0), prevHash: tipRow.prev ?? null }) @@ -70,3 +73,38 @@ export const commitNominationVoteProjection = async ( ) return rows.length } + +// P1 entry point — kept for src/data/nominations.ts. +export const commitNominationVoteProjection = ( + client: WalClient, + input: { + nominationId: number + reviewerId: string + choice: "approve" | "decline" + resultKind: + | "recorded" + | "switched" + | "granting" + | "declined" + | "expired" + status: string + totals: { approvals: number; declines: number } + completedAt: string | null + mutationId: string + ts: string + } +): Promise => + commitProjection(client, { mutationId: input.mutationId, ts: input.ts }, (kernel) => { + const base = `nomination.${input.nominationId}` + // the nomination itself is a cell — link endpoints must exist (L1 law) + kernel.bind(base, { id: input.nominationId, kind: "nomination" }) + kernel.bind(`${base}.mutation`, input.mutationId, { ts: input.ts }) + kernel.bind(`${base}.status`, input.status) + kernel.bind(`${base}.totals`, input.totals) + kernel.bind(`${base}.completedAt`, input.completedAt) + if (input.resultKind !== "expired") { + const voteCell = `${base}.vote.${input.reviewerId}` + kernel.bind(voteCell, input.choice, { ts: input.ts }) + kernel.link(voteCell, base, "cast") + } + }) diff --git a/src/quilt/projection.ts b/src/quilt/projection.ts index e111f2f..eb69e64 100644 --- a/src/quilt/projection.ts +++ b/src/quilt/projection.ts @@ -88,21 +88,37 @@ export const buildWalRows = ( let prevHash = context.prevHash ?? GENESIS let seq = context.tip for (const event of events) { - if (event.cell === null) continue // tick/load carry no cell state - seq += 1 - const op = event.kind === "unbind" ? "unbind" : event.kind - const value = + // structural events carry no cell — synthesize an edge row so the + // graph survives the WAL (replay ignores non-bind ops) + let cell = event.cell + let op = event.kind + let value = event.value === null || event.value === undefined ? null : JSON.stringify(event.value) + if (cell === null) { + const edge = event.value as { from?: string; to?: string; type?: string; id?: string } | null + if ( + (event.kind === "link" || event.kind === "unlink") && + edge?.from && + edge?.to + ) { + cell = `${edge.from}->${edge.to}` + op = event.kind + value = JSON.stringify({ id: edge.id ?? `${edge.from}->${edge.to}:${edge.type ?? ""}`, type: edge.type ?? null }) + } else { + continue // tick/load carry no state + } + } + seq += 1 const hash = fnv1a( - `${prevHash}|${seq}|${event.cell}|${op}|${value ?? ""}|${context.ts}|${context.mutationId}` + `${prevHash}|${seq}|${cell}|${op}|${value ?? ""}|${context.ts}|${context.mutationId}` ) rows.push({ seq, mutation_id: context.mutationId, ts: context.ts, - cell: event.cell, + cell, op, value, prev_hash: prevHash, @@ -167,3 +183,213 @@ export const replayNominationFromWal = ( mutationId: (cells.get(`${prefix}mutation`) as string | null) ?? null } } + +// ─── P2: the encounter machine, including its negative space ───────────── +// +// createLobsterEncounter gates the encounter insert on changes()=1 across a +// quadruple NOT EXISTS guard (actor / target / channel / already-exists). +// Most attempts in the wild are REFUSALS. The negative ledger makes a +// refusal a first-class WAL row: the reef of encounters that never were. +// H(N) > H(R): the absent encounters carry more structure than the +// present ones — the refusal rows ARE the cooldown topology. + +export type EncounterProjectionInput = { + resultKind: "created" | "existing" | "publication_failed" | "cooldown" + interactionId: string + attempt: { + guildId: string + channelId: string + actorId: string + targetId: string + } + // present when kind === "created" + encounter?: { + id: number + speciesDisplayName: string + publicationStatus: string + } + // present when kind === "cooldown": which guards fired + refusedBy?: Array<"actor" | "target" | "channel"> + remaining?: Array<{ kind: string; remainingSeconds: number }> + ts: string +} + +export const projectLobsterEncounter = ( + kernel: KernelLike, + input: EncounterProjectionInput +): void => { + const base = `encounter.${input.interactionId}` + kernel.bind(base, { + kind: "lobster-encounter-attempt", + guildId: input.attempt.guildId, + channelId: input.attempt.channelId, + actorId: input.attempt.actorId, + targetId: input.attempt.targetId + }) + + if (input.resultKind === "cooldown") { + // The negative ledger: this encounter does not exist, and that is + // the information. The guard dimensions that fired are the payload. + kernel.bind(`${base}.refused`, { + refusedBy: input.refusedBy ?? [], + remaining: input.remaining ?? [], + ts: input.ts + }) + return + } + + if (input.resultKind === "created" && input.encounter) { + const cell = `encounter.${input.encounter.id}` + kernel.bind(cell, { id: input.encounter.id, kind: "lobster-encounter" }) + // the attempt cell and the encounter cell are one identity — the + // kernel LINK is the changes()=1 linkage, made traversable + kernel.link(base, cell, "resolved") + kernel.bind(`${cell}.actor`, input.attempt.actorId) + kernel.bind(`${cell}.target`, input.attempt.targetId) + kernel.bind(`${cell}.species`, input.encounter.speciesDisplayName) + kernel.bind(`${cell}.publication`, input.encounter.publicationStatus) + kernel.bind(`${cell}.interaction`, input.interactionId) + kernel.bind(`${cell}.createdAt`, input.ts) + return + } + + // existing / publication_failed: an idempotent retry. Presence of this + // row marks the revisit; live D1 row remains the authority on status. + kernel.bind(`${base}.retried`, input.ts) +} + +export type ResponseProjectionInput = { + encounterId: number + responseType: "return_to_sender" | "offer_butter" + responderId: string + ts: string +} + +export const projectLobsterResponse = ( + kernel: KernelLike, + input: ResponseProjectionInput +): void => { + const base = `encounter.${input.encounterId}` + kernel.bind(`${base}.response`, { + type: input.responseType, + responderId: input.responderId, + ts: input.ts + }) +} + +export type PublicationProjectionInput = { + encounterId: number + kind: "bound" | "already_bound" | "publication_failed" + messageId?: string + failure?: string + ts: string +} + +export const projectLobsterPublication = ( + kernel: KernelLike, + input: PublicationProjectionInput +): void => { + const base = `encounter.${input.encounterId}` + if (input.kind === "bound") { + kernel.bind(`${base}.message`, input.messageId ?? null) + kernel.bind(`${base}.publication`, "published") + } else if (input.kind === "already_bound") { + kernel.bind(`${base}.message`, input.messageId ?? null) + } else { + kernel.bind(`${base}.publication`, "publication_failed") + kernel.bind(`${base}.failure`, input.failure ?? null) + } +} + +export type RefusalRecord = { + interactionId: string + actorId: string + targetId: string + channelId: string + refusedBy: string[] + remaining: Array<{ kind: string; remainingSeconds: number }> + ts: string +} + +// The negative-space instrument: walk the WAL and return every refused +// encounter attempt. This query is IMPOSSIBLE against live D1 — refusals +// leave no rows there. The ledger sees what the reef cannot. +export const analyzeRefusals = ( + rows: WalRow[], + guildId?: string +): RefusalRecord[] => { + const records: RefusalRecord[] = [] + for (const row of rows) { + if (row.op !== "bind" || !row.cell.endsWith(".refused")) continue + const payload = JSON.parse(row.value ?? "{}") as { + refusedBy?: string[] + remaining?: Array<{ kind: string; remainingSeconds: number }> + ts?: string + } + const parts = row.cell.split(".") + const interactionId = parts[1] + const attemptRow = rows.find( + (candidate) => + candidate.op === "bind" && + candidate.cell === `encounter.${interactionId}` && + candidate.seq < row.seq + ) + const attempt = JSON.parse(attemptRow?.value ?? "{}") as { + guildId?: string + channelId?: string + actorId?: string + targetId?: string + } + if (guildId && attempt.guildId !== guildId) continue + records.push({ + interactionId, + actorId: attempt.actorId ?? "", + targetId: attempt.targetId ?? "", + channelId: attempt.channelId ?? "", + refusedBy: payload.refusedBy ?? [], + remaining: payload.remaining ?? [], + ts: payload.ts ?? row.ts + }) + } + return records +} + +// Replay an encounter's positive state from the WAL (created/bound/ +// responded path). Refusals never reach here — that is the point. +export type ReplayedEncounter = { + actorId: string | null + targetId: string | null + species: string | null + publication: string | null + message: string | null + response: { type: string; responderId: string; ts: string } | null + createdAt: string | null + interactionId: string | null +} + +export const replayEncounterFromWal = ( + rows: WalRow[], + encounterId: number +): ReplayedEncounter | null => { + const prefix = `encounter.${encounterId}.` + const cells = new Map() + for (const row of rows) { + if (row.op !== "bind" || !row.cell.startsWith(prefix)) continue + cells.set(row.cell, row.value === null ? null : JSON.parse(row.value)) + } + if (cells.size === 0) return null + return { + actorId: (cells.get(`${prefix}actor`) as string | null) ?? null, + targetId: (cells.get(`${prefix}target`) as string | null) ?? null, + species: (cells.get(`${prefix}species`) as string | null) ?? null, + publication: + (cells.get(`${prefix}publication`) as string | null) ?? null, + message: (cells.get(`${prefix}message`) as string | null) ?? null, + response: + (cells.get(`${prefix}response`) as ReplayedEncounter["response"]) ?? + null, + createdAt: (cells.get(`${prefix}createdAt`) as string | null) ?? null, + interactionId: + (cells.get(`${prefix}interaction`) as string | null) ?? null + } +} diff --git a/tests/quiltKernelEncounters.test.ts b/tests/quiltKernelEncounters.test.ts new file mode 100644 index 0000000..4d34dec --- /dev/null +++ b/tests/quiltKernelEncounters.test.ts @@ -0,0 +1,325 @@ +// quiltKernelEncounters.test.ts — the encounter machine's kernel shadow, +// including the negative ledger: refusals as first-class WAL rows. +import { Database } from "bun:sqlite" +import { describe, expect, it } from "bun:test" +import { readdirSync, readFileSync } from "node:fs" +import { drizzle } from "drizzle-orm/d1" +import * as schema from "../src/db/schema.js" +import { + analyzeRefusals, + replayEncounterFromWal, + verifyChain, + type WalRow +} from "../src/quilt/projection.js" +import { + createLobsterEncounter, + bindLobsterMessage, + markLobsterPublicationFailed, + recordLobsterResponse, + type CreateLobsterEncounterInput, + type LobsterDatabase +} from "../src/data/lobsterEncounters.js" +import { SqliteD1Database } from "./helpers/sqliteD1.js" + +// Same proof-harness contract as quiltKernelReplay.test.ts, plus 0010 +// (slap_events, which 0011's GC references) and 0011 (the encounter +// machine's own tables). Existing suites filter 000[4-9] +// and never see 0013 — unchanged suites stay honest. +const migrationPaths = readdirSync("drizzle") + .filter((file) => + /0002_.*\.sql|000[4-9]_.*\.sql|001[013]_.*\.sql/.test(file) + ) + .sort() + +const applyMigrations = (database: Database) => { + for (const path of migrationPaths) { + const migration = readFileSync(`drizzle/${path}`, "utf8") + for (const statement of migration.split("--> statement-breakpoint")) { + const trimmed = statement.trim() + if (trimmed) { + database.run(trimmed) + } + } + } +} + +const createHarness = () => { + const owner = new SqliteD1Database() + applyMigrations(owner.database) + const database = drizzle(owner as unknown as never, { + schema + }) as LobsterDatabase + return { owner, database } +} + +const readWalRows = async ( + owner: SqliteD1Database +): Promise => { + const result = await owner + .prepare( + `select seq, mutation_id, ts, cell, op, value, prev_hash, hash + from quilt_wal order by seq asc` + ) + .all() + return (result.results ?? []).map((row) => ({ + seq: Number(row.seq), + mutation_id: row.mutation_id, + ts: row.ts, + cell: row.cell, + op: row.op, + value: row.value, + prev_hash: row.prev_hash, + hash: row.hash + })) +} + +const encounterInput = ( + overrides: Partial +): CreateLobsterEncounterInput => ({ + interactionId: `interaction-${Math.random().toString(36).slice(2, 10)}`, + guildId: "guild-1", + channelId: "channel-1", + actorId: "actor-1", + targetId: "target-1", + targetIsBot: false, + taxonomySnapshotId: "snapshot-1", + speciesAphiaId: 107176, + speciesAcceptedName: "Homarus gammarus", + speciesDisplayName: "European lobster", + speciesFamily: "Nephropidae", + sceneId: "scene-reef-1", + assetUrl: "https://example.test/lobster.png", + assetChecksum: "checksum-1", + headline: "A lobster appears", + narrative: "Claws up, antennae sweeping the current.", + metrics: { weightKg: 1.5 }, + accessibilityDescription: "A blue lobster on a rocky reef.", + ...overrides +}) + +describe("quilt kernel — lobster encounter shadow", () => { + it("projects a created encounter; replay matches the D1 row", async () => { + const { owner, database } = createHarness() + const input = encounterInput({ interactionId: "ix-created" }) + const result = await createLobsterEncounter( + input, + new Date("2026-09-17T00:00:00Z"), + database + ) + expect(result.kind).toBe("created") + if (result.kind !== "created") return + + const rows = await readWalRows(owner) + expect(rows.length).toBeGreaterThan(0) + const cells = new Set(rows.map((row) => row.cell)) + const cell = `encounter.${result.encounter.id}` + expect(cells.has(`encounter.ix-created`)).toBe(true) + expect(cells.has(`${cell}.species`)).toBe(true) + expect(cells.has(`${cell}.publication`)).toBe(true) + // the changes()=1 linkage, made traversable: attempt -> encounter + expect(cells.has(`encounter.ix-created->${cell}`)).toBe(true) + expect(verifyChain(rows)).toBe(true) + + const replayed = replayEncounterFromWal(rows, result.encounter.id) + expect(replayed).not.toBeNull() + expect(replayed?.actorId).toBe("actor-1") + expect(replayed?.targetId).toBe("target-1") + expect(replayed?.species).toBe("European lobster") + expect(replayed?.publication).toBe("pending") + expect(replayed?.interactionId).toBe("ix-created") + }) + + it("records cooldown refusals in the negative ledger — invisible to D1", async () => { + const { owner, database } = createHarness() + const t0 = new Date("2026-09-17T00:00:00Z") + const first = await createLobsterEncounter( + encounterInput({ + interactionId: "ix-first", + actorId: "actor-A", + targetId: "target-B", + channelId: "channel-C" + }), + t0, + database + ) + expect(first.kind).toBe("created") + + // same actor, different target and channel → actor guard fires + const byActor = await createLobsterEncounter( + encounterInput({ + interactionId: "ix-denied-actor", + actorId: "actor-A", + targetId: "target-D", + channelId: "channel-E" + }), + t0, + database + ) + expect(byActor.kind).toBe("cooldown") + if (byActor.kind === "cooldown") { + expect(byActor.cooldowns.map((c) => c.kind)).toEqual(["actor"]) + } + + // different actor, same target → target guard fires + const byTarget = await createLobsterEncounter( + encounterInput({ + interactionId: "ix-denied-target", + actorId: "actor-F", + targetId: "target-B", + channelId: "channel-E" + }), + t0, + database + ) + expect(byTarget.kind).toBe("cooldown") + if (byTarget.kind === "cooldown") { + expect(byTarget.cooldowns.map((c) => c.kind)).toEqual(["target"]) + } + + // different actor and target, same channel → channel guard fires + const byChannel = await createLobsterEncounter( + encounterInput({ + interactionId: "ix-denied-channel", + actorId: "actor-F", + targetId: "target-G", + channelId: "channel-C" + }), + t0, + database + ) + expect(byChannel.kind).toBe("cooldown") + if (byChannel.kind === "cooldown") { + expect(byChannel.cooldowns.map((c) => c.kind)).toEqual(["channel"]) + } + + // the negative-space instrument: three refusals, one per guard + const rows = await readWalRows(owner) + const refusals = analyzeRefusals(rows, "guild-1") + expect(refusals).toHaveLength(3) + const byDimension = refusals + .map((record) => record.refusedBy[0]) + .sort() + expect(byDimension).toEqual(["actor", "channel", "target"]) + const actorRefusal = refusals.find((r) => + r.refusedBy.includes("actor") + ) + expect(actorRefusal?.interactionId).toBe("ix-denied-actor") + expect(actorRefusal?.remaining?.[0]?.remainingSeconds).toBeGreaterThan(0) + + // H(N) > H(R), verified: live D1 holds ONE encounter; the WAL saw four + const liveCount = await owner + .prepare(`select count(*) as n from lobster_encounters`) + .all<{ n: number }>() + expect(Number(liveCount.results?.[0]?.n ?? 0)).toBe(1) + expect(verifyChain(rows)).toBe(true) + }) + + it("records an idempotent retry as a retried row", async () => { + const { owner, database } = createHarness() + const t0 = new Date("2026-09-17T00:00:00Z") + const input = encounterInput({ interactionId: "ix-retry" }) + const first = await createLobsterEncounter(input, t0, database) + expect(first.kind).toBe("created") + const retry = await createLobsterEncounter(input, t0, database) + expect(retry.kind).toBe("existing") + + const rows = await readWalRows(owner) + const retried = rows.find( + (row) => row.cell === "encounter.ix-retry.retried" + ) + expect(retried?.op).toBe("bind") + expect(verifyChain(rows)).toBe(true) + }) + + it("projects bind → response; replay shows the full lifecycle", async () => { + const { owner, database } = createHarness() + const t0 = new Date("2026-09-17T00:00:00Z") + const created = await createLobsterEncounter( + encounterInput({ interactionId: "ix-life" }), + t0, + database + ) + if (created.kind !== "created") throw new Error("setup failed") + const id = created.encounter.id + + const bound = await bindLobsterMessage( + id, "guild-1", "channel-1", "msg-1", t0, database + ) + expect(bound.kind).toBe("bound") + + const responded = await recordLobsterResponse( + { + encounterId: id, + guildId: "guild-1", + channelId: "channel-1", + messageId: "msg-1", + responderId: "target-1", + responderIsBot: false, + responseType: "offer_butter", + responseResult: { buttered: true } + }, + t0, + database + ) + expect(responded.kind).toBe("recorded") + + const rows = await readWalRows(owner) + const replayed = replayEncounterFromWal(rows, id) + expect(replayed?.publication).toBe("published") + expect(replayed?.message).toBe("msg-1") + expect(replayed?.response?.type).toBe("offer_butter") + expect(replayed?.response?.responderId).toBe("target-1") + expect(verifyChain(rows)).toBe(true) + }) + + it("projects publication failure and the replay shows it", async () => { + const { owner, database } = createHarness() + const t0 = new Date("2026-09-17T00:00:00Z") + const created = await createLobsterEncounter( + encounterInput({ interactionId: "ix-pubfail" }), + t0, + database + ) + if (created.kind !== "created") throw new Error("setup failed") + + const failed = await markLobsterPublicationFailed( + created.encounter.id, "discord 500", t0, database + ) + expect(failed.kind).toBe("marked_failed") + + const rows = await readWalRows(owner) + const replayed = replayEncounterFromWal(rows, created.encounter.id) + expect(replayed?.publication).toBe("publication_failed") + expect(verifyChain(rows)).toBe(true) + }) + + it("keeps the chain valid across mixed traffic", async () => { + const { owner, database } = createHarness() + const t0 = new Date("2026-09-17T00:00:00Z") + await createLobsterEncounter( + encounterInput({ + interactionId: "ix-mix-1", + actorId: "actor-M", + targetId: "target-N", + channelId: "channel-O" + }), + t0, + database + ) + await createLobsterEncounter( + encounterInput({ + interactionId: "ix-mix-denied", + actorId: "actor-M", + targetId: "target-P", + channelId: "channel-Q" + }), + t0, + database + ) + const rows = await readWalRows(owner) + expect(rows.length).toBeGreaterThan(8) + const seqs = rows.map((row) => row.seq) + expect(new Set(seqs).size).toBe(seqs.length) + expect(verifyChain(rows)).toBe(true) + }) +}) diff --git a/tests/quiltKernelReplay.test.ts b/tests/quiltKernelReplay.test.ts index 42c3c48..4d6872f 100644 --- a/tests/quiltKernelReplay.test.ts +++ b/tests/quiltKernelReplay.test.ts @@ -148,7 +148,9 @@ describe("quilt kernel P1 — nomination WAL projection", () => { const again = await recordNominationVote(id, "ct-1", "approve", new Date(), database) expect(again.kind).toBe("unchanged") - const votes = walRows(owner).filter((row) => row.cell.includes(".vote.")) + const votes = walRows(owner).filter( + (row) => row.op === "bind" && row.cell.includes(".vote.") + ) expect(votes.length).toBe(1) owner.close() })