From 1837997ed187de07e8590f9fc845b84c279a2137 Mon Sep 17 00:00:00 2001 From: Ben Richards Date: Fri, 9 Oct 2026 14:17:26 +0300 Subject: [PATCH] =?UTF-8?q?S134=20=E2=80=94=20Saves=20and=20branches=20car?= =?UTF-8?q?ry=20their=20epochs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A save on a held epoch loads unmigrated and replayable, then reaches loadGame's adoption point; a migration that restamps a crossed log appends a migration entry. branchSession replays content entries through adoptContent from the log's starting version, checking every named epoch before writing anything. --- design/30-slices.md | 2 +- design/90-decisions.md | 25 ++ docs/docs/engine/TODO.md | 2 +- .../src/core/persistence/envelope.test.ts | 9 + src/engine/src/core/persistence/envelope.ts | 36 +- src/engine/src/core/session/store.test.ts | 402 +++++++++++++++--- src/engine/src/core/session/store.ts | 62 ++- 7 files changed, 458 insertions(+), 80 deletions(-) diff --git a/design/30-slices.md b/design/30-slices.md index fb25241..1fe043d 100644 --- a/design/30-slices.md +++ b/design/30-slices.md @@ -436,7 +436,7 @@ Out of scope: Adoption at `loadGame` and `branchSession` (S134); catalog refresh ## S134 — Saves and Branches Carry Their Epochs -Status: todo +Status: done Delivers: A save made on an older version of a campaign loads exactly as it was made when that version is still held, then moves forward like any session, and stays replayable. Branching a session that moved onto new content replays it through the same moves, diff --git a/design/90-decisions.md b/design/90-decisions.md index a2d3eec..32534cd 100644 --- a/design/90-decisions.md +++ b/design/90-decisions.md @@ -2666,6 +2666,31 @@ Context: C27's union resolves every live epoch through `engine.content.resolve`. Chosen: an epoch that does not resolve contributes nothing, and the union of the rest is returned. Rejected: **Throwing.** `getStrings` is a query, and a missing table for an older epoch should not stop a client from reading the current one. A session on an unresolvable current epoch already fails its other operations. +### 2026-10-09 — S134: a save loads on its own epoch only when its `kindVersion` is the registered kind's +Context: §7's load-path refinement loads a save on its own epoch, unmigrated, when that epoch is still held. A save can also carry an older `kindVersion`, and `Kind.migrateState` runs against the campaign the load resolves. +Chosen: `resolveSaveEnvelope` takes the save's own epoch from `engine.content.resolve` only when the save's `kindVersion` equals the registered kind's. Any other save, including one whose epoch is held but whose kind shape moved, takes §10.2's path onto the registry's version. `resolveSaveEnvelope` takes the resolver as an optional fourth argument, so a caller without an archive keeps the registry-only behaviour. +Rejected: **Resolving the held epoch and then migrating only the kind state.** It is a third load path, a kind migration against an older campaign than the one it was written for. The registry path already covers the case at the cost of `replayCompatible`. + +### 2026-10-09 — S134: the envelope's `campaignVersion` must equal the embedded state's +Context: the checksum covers `state` and `replayCompatible`, not the wrapper fields, which is why `kindId` and `campaignId` are cross-checked against the state. Once a save is resolved and migrated from the wrapper's `campaignVersion`, an edited wrapper version would migrate content the state never ran, or skip a migration it needs. +Chosen: a wrapper `campaignVersion` that differs from `state.campaignVersion` is `invalid_state`, alongside the existing id checks. `saveGame` stamps the version of the epoch the state is on, through `engine.content.resolve`, so the two always agree for saves this engine writes. +Rejected: **Reading the version from the state alone.** It would leave the wrapper field unchecked while every other wrapper field is checked. + +### 2026-10-09 — S134: a migration entry's `from` is the state's `campaignVersion` +Context: §3.5 appends `{ seq, system: "migration", from, to }` when a migration changes `campaignVersion` on a log that carries an epoch entry. +Chosen: `from` is the embedded state's `campaignVersion`, which the cross-check above makes equal to the wrapper's, and `to` is the registry campaign's version. The entry is appended after the kind judges the migrated state, so a refused migration appends nothing. +Rejected: none considered. + +### 2026-10-09 — S134: `branchSession` checks every version the replay stands on before it creates anything +Context: §7's failure table gives `branchSession` `unknown_campaign` when its starting version or a retained adoption's version no longer resolves, and requires nothing written. Replaying until the missing epoch is reached would find it late, and `adoptContent` reports an unheld target as a refusal, which is `invalid_state`. +Chosen: before the branch's session id is minted, the store resolves the starting version and every retained content entry's `to` through `engine.content.resolve`, requiring the resolved campaign's version to equal the name, as the kernel does. Any miss is `unknown_campaign`. A recorded adoption that then refuses is `invalid_state`. A log carrying a migration entry is `invalid_state` before either check, read from the log rather than only from the record's `replayCompatible`. +Rejected: **Letting the replay fail where it fails.** It would report a withdrawn epoch as `invalid_state`, and the code is the only thing a client can act on. + +### 2026-10-09 — S134: two store tests written before S134 are superseded, not kept +Context: S130 added a test that `branchSession` refuses a crossed log with `invalid_state`, and W99 a test that a store whose registry was mutated after creation refuses with `unknown_campaign`. +Chosen: both are removed. The first asserted the interim refusal S134 retires. The second relied on the store's own registry deciding a branch, which no longer happens: the engine's archive decides, and S134.4's tests cover a starting version or adopted version that is no longer held. +Rejected: **Rewriting the second against a mutated registry.** The engine snapshots what it holds, so mutating the store's registry no longer reaches a branch, and the test would assert nothing. + ### 2026-10-08 — `getStrings` returns the union of a session's live epochs, newest winning per key (red-team F3) Context: red-team F3 (`design/redteam/2026-10-08-10-design.md`, STRUCTURAL). A client calls `getScene` on `e1`, and then another client's `resumeSession` adopts `e2`, which renamed a label key. The first client's `getStrings` then returned only the `e2` table, so its scene carried a key it could not resolve. Neither `Scene` nor `StringTable` carries an epoch, so the client could not detect this. The race is between completed calls, so the session lock cannot close it. Chosen: `getStrings(sessionId)` returns the union of the string tables of the session's live epochs. Those are the starting version, or the last migration entry's `to`, and every content entry's `to` after it. Each key resolves from the most recent of those epochs that defines it. New invariant C27: a session's table never loses a key, so any key in any result the store returned for a session resolves in every later `getStrings`. A client whose cached table lacks a key fetches the table again. The union also covers a `submitAction` that adopts after commit, whose messages come from the old epoch while its scene comes from the new one. Accepted residual: a key both epochs define resolves to the newer wording, even on an older scene. No type, signature or MCP schema changes, and the union spans only epochs the log already names, which retention already keeps. The design is `16-content-epochs.md` §5.7 and Clients §2; the contract is `20-contract.md` C27 and §7. diff --git a/docs/docs/engine/TODO.md b/docs/docs/engine/TODO.md index 31cf4a5..545d5e8 100644 --- a/docs/docs/engine/TODO.md +++ b/docs/docs/engine/TODO.md @@ -430,7 +430,7 @@ Out of scope: Adoption at `loadGame` and `branchSession` (S134); catalog refresh ## S134 — Saves and Branches Carry Their Epochs -Status: todo +Status: done Delivers: A save made on an older version of a campaign loads exactly as it was made when that version is still held, then moves forward like any session, and stays replayable. Branching a session that moved onto new content replays it through the same moves, diff --git a/src/engine/src/core/persistence/envelope.test.ts b/src/engine/src/core/persistence/envelope.test.ts index db2fb95..46361be 100644 --- a/src/engine/src/core/persistence/envelope.test.ts +++ b/src/engine/src/core/persistence/envelope.test.ts @@ -334,6 +334,15 @@ describe("resolveSaveEnvelope — the kind judges the kindState it would run (S1 expect(resolution).toEqual({ ok: false, code: "migration_failed" }); }); + it("a wrapper campaignVersion that disagrees with the embedded state's is invalid_state", () => { + // The wrapper names the epoch the save is resolved and migrated from (16 §7); one that is + // not the state's own would migrate content the state never ran. + const parsed = JSON.parse(v1Blob()) as Record; + parsed["campaignVersion"] = "2.0.0"; + const resolution = resolveSaveEnvelope(JSON.stringify(parsed), makeKinds(makeKind("1.0.0")), makeRegistry(makeCampaign("1.0.0", campaignMigrateV1toV2))); + expect(resolution).toEqual({ ok: false, code: "invalid_state" }); + }); + it("a validateState that throws is a refusal, not an uncaught exception", () => { const throwing: Kind = { ...makeKind("1.0.0"), validateState: () => { throw new Error("malformed"); } }; const resolution = resolveSaveEnvelope(v1Blob(), makeKinds(throwing), makeRegistry(makeCampaign("1.0.0"))); diff --git a/src/engine/src/core/persistence/envelope.ts b/src/engine/src/core/persistence/envelope.ts index f87449a..b53b483 100644 --- a/src/engine/src/core/persistence/envelope.ts +++ b/src/engine/src/core/persistence/envelope.ts @@ -10,8 +10,9 @@ */ import type { Campaign, ContentRegistry } from "../registry/types.js"; -import type { GameState, Kind, KindRegistry } from "../kernel/types.js"; +import type { GameState, Kind, KindRegistry, LoggedMigration } from "../kernel/types.js"; import { isAcceptedKindState, isValidGameStateShape } from "../kernel/engine.js"; +import { isEpochEntry } from "../kernel/log.js"; import { canonicalize as canonicalStringify, sha256Hex } from "subzerodev-data-json"; import type { SaveEnvelope } from "./types.js"; import { ENGINE_VERSION } from "../../version.js"; @@ -127,7 +128,15 @@ function invokeMigration( * `engineVersion` is never checked here at all: informational only, per §10.2's own * "changes independently" rationale. */ -export function resolveSaveEnvelope(blob: string, kinds: KindRegistry, registry: ContentRegistry): SaveEnvelopeResolution { +/** Resolves a held epoch — `engine.content.resolve` (16 §4.1). */ +export type EpochResolver = (campaignId: string, campaignVersion: string) => ContentRegistry | undefined; + +export function resolveSaveEnvelope( + blob: string, + kinds: KindRegistry, + registry: ContentRegistry, + resolveEpoch?: EpochResolver, +): SaveEnvelopeResolution { let parsed: unknown; try { parsed = JSON.parse(blob); @@ -150,7 +159,14 @@ export function resolveSaveEnvelope(blob: string, kinds: KindRegistry, registry: return { ok: false, code: "save_requires_migration" }; } - const campaign = registry.campaigns.get(parsed.campaignId); + // 16 §7, the load-path refinement: a save whose own epoch is still held and whose kind shape + // is current loads exactly as it was made — on that epoch, unmigrated, replayable — and moves + // forward by logged adoption instead. Anything else migrates onto the registry's version. + const heldKind = kinds[parsed.kindId]; + const held = heldKind && parsed.kindVersion === heldKind.version + ? resolveEpoch?.(parsed.campaignId, parsed.campaignVersion)?.campaigns.get(parsed.campaignId) + : undefined; + const campaign = held ?? registry.campaigns.get(parsed.campaignId); if (!campaign) return { ok: false, code: "unknown_campaign" }; const kind = kinds[parsed.kindId]; if (!kind) return { ok: false, code: "unknown_kind" }; @@ -163,6 +179,9 @@ export function resolveSaveEnvelope(blob: string, kinds: KindRegistry, registry: if (campaign.kindId !== parsed.kindId) return { ok: false, code: "invalid_state" }; if (parsed.state.kindId !== parsed.kindId) return { ok: false, code: "invalid_state" }; if (parsed.state.campaignId !== parsed.campaignId) return { ok: false, code: "invalid_state" }; + // The wrapper's version is the epoch the save is resolved and migrated from, so it must be + // the one the state was on — or a migration would restamp content it never ran. + if (parsed.state.campaignVersion !== parsed.campaignVersion) return { ok: false, code: "invalid_state" }; let kindState = parsed.state.kindState; let migrated = false; @@ -190,6 +209,15 @@ export function resolveSaveEnvelope(blob: string, kinds: KindRegistry, registry: return { ok: false, code: migrated ? "migration_failed" : "invalid_state" }; } - const state: GameState = { ...parsed.state, kindState, campaignVersion: campaign.version }; + // 16 §3.5: a restamp over a log that already carries an epoch entry is logged, so the last + // entry's `to` still names `campaignVersion` (C26). A log with none stays version 1 and needs + // none; a `kindVersion`-only migration restamps nothing; a save already on `campaign.version` + // — including one this path produced — moves nothing and appends nothing. + const log = parsed.state.actionLog; + const restamped = parsed.state.campaignVersion !== campaign.version && log.some(isEpochEntry); + const actionLog = restamped + ? [...log, { seq: log.length, system: "migration", from: parsed.state.campaignVersion, to: campaign.version } satisfies LoggedMigration] + : log; + const state: GameState = { ...parsed.state, kindState, campaignVersion: campaign.version, actionLog }; return { ok: true, state, replayCompatible: parsed.replayCompatible && !migrated }; } diff --git a/src/engine/src/core/session/store.test.ts b/src/engine/src/core/session/store.test.ts index 0d4abff..a30c179 100644 --- a/src/engine/src/core/session/store.test.ts +++ b/src/engine/src/core/session/store.test.ts @@ -1669,42 +1669,6 @@ describe("session lifecycle — listSaves / deleteSave / branchSession (04 §7.4 expect(putCalls).toBe(0); }); - it("refuses a log that crossed an epoch with invalid_state, writing nothing, until it can replay one (S134)", async () => { - const second = makeRegistry([makeCampaign({ version: "2" })]); - const first = makeRegistry(); - const kinds = { - "story-graph": { ...makeTestKind(), adoptContent: (state: TestKindState) => ({ adopt: true as const, state }) }, - } as unknown as KindRegistry; - const engine = makeEngine({ - kinds, - registry: first, - archive: { resolve: (id, version) => (id !== "test-campaign" ? undefined : version === "1" ? first : version === "2" ? second : undefined) }, - }); - const created = engine.createGame({ campaignId: "test-campaign", seed: "fixed-seed" }); - const adoption = engine.adoptContent(created.value!, "2"); - if (!adoption.adopted) throw new Error("expected the test kind to adopt"); - const record: StoredSessionRecord = { - sessionId: "crossed", - blob: engine.serialize(adoption.state), - audience: "player", - attemptCounter: 1, - revision: 1, - replayCompatible: true, - createdAt: "2026-01-01T00:00:00.000Z", - updatedAt: "2026-01-01T00:00:00.000Z", - }; - let putCalls = 0; - const store = makeStore({ - engine, - persistence: persistenceWith({ - sessions: { get: async (id) => (id === "crossed" ? record : undefined), put: async () => { putCalls += 1; } }, - }), - }); - - await expect(store.branchSession("crossed", 0)).rejects.toMatchObject({ code: "invalid_state" }); - expect(putCalls).toBe(0); - }); - it("B3 — a branch's sessionId is distinct from its source, and its gameId equals the source's", async () => { const store = makeStore(); const { sessionId } = await store.createSession({ campaignId: "test-campaign" }); @@ -1773,19 +1737,6 @@ describe("session lifecycle — listSaves / deleteSave / branchSession (04 §7.4 }); }); - it("unknown_campaign — the source's campaignVersion is no longer registered", async () => { - const campaigns = new Map([["test-campaign", makeCampaign({ version: "1" })]]); - const mutableRegistry: ContentRegistry = { campaigns, strings: makeRegistry().strings }; - const store = createInMemorySessionStore({ engine: makeEngine({ registry: mutableRegistry }), registry: mutableRegistry }); - const { sessionId } = await store.createSession({ campaignId: "test-campaign" }); - - campaigns.set("test-campaign", makeCampaign({ version: "2" })); - - await expect(store.branchSession(sessionId, 0)).rejects.toMatchObject({ - operation: "branchSession", - code: "unknown_campaign", - }); - }); }); describe("reproducing a stored session from its log (04 §7.4, W99.6)", () => { @@ -2172,17 +2123,23 @@ function makeEpochStore(options: { persistence?: SessionPersistence; profiles?: ProfileStore; recordSink?: EmittedRecordSink; + /** The host's own content: its registry, the archive's other epochs, and its kind. */ + kind?: Kind; + initial?: ContentRegistry; + publish?: ContentRegistry[]; + recordIds?: RecordIdSource; }): { store: SessionStore; engine: Engine } { - const kinds = { "story-graph": makeEpochKind() } as unknown as KindRegistry; - const archive = createContentArchive({ kinds, initial: EPOCH_V1 }); - for (const registry of [EPOCH_V2, EPOCH_V3_REFUSED]) { + const kinds = { "story-graph": options.kind ?? makeEpochKind() } as unknown as KindRegistry; + const initial = options.initial ?? EPOCH_V1; + const archive = createContentArchive({ kinds, initial }); + for (const registry of options.publish ?? [EPOCH_V2, EPOCH_V3_REFUSED]) { if (!archive.publish(registry).ok) throw new Error("expected the test epoch to publish"); } - const engine = createEngine({ kinds, registry: EPOCH_V1, archive, ids: createCountingIds() }); + const engine = createEngine({ kinds, registry: initial, archive, ids: createCountingIds() }); const store = createSessionLayer({ engine, - registry: EPOCH_V1, - recordIds: makeCountingRecordIds(), + registry: initial, + recordIds: options.recordIds ?? makeCountingRecordIds(), ...(options.channel ? { content: options.channel } : {}), ...(options.persistence ? { persistence: options.persistence } : {}), ...(options.profiles ? { profiles: options.profiles } : {}), @@ -2472,3 +2429,338 @@ describe("S133.6 — getStrings spans every live epoch", () => { expect(await store.getStrings("migrated")).toEqual(tableOf(EPOCH_V2)); }); }); + +/** One set of session and save rows several stores share — a later process reading what an + * earlier one wrote — with each store minting its own ids, and every session write counted. */ +function makeSharedRows(): { + persistence: SessionPersistence; + rows: Map; + readonly sessionPuts: number; +} { + const cas = makeCasPersistence(); + const saves = new Map(); + let sessionPuts = 0; + return { + rows: cas.rows, + get sessionPuts() { return sessionPuts; }, + persistence: persistenceWith({ + sessions: { + get: cas.persistence.sessions.get, + put: async (record) => { + sessionPuts += 1; + await cas.persistence.sessions.put(record); + }, + }, + saves: { + get: async (saveId) => saves.get(saveId), + put: async (record) => { saves.set(record.saveId, { ...record }); }, + }, + }), + }; +} + +function prefixedRecordIds(prefix: string): RecordIdSource { + let sessions = 0; + let saves = 0; + return { + newSessionId: () => `${prefix}-session-${sessions++}`, + newSaveId: () => `${prefix}-save-${saves++}`, + }; +} + +/** A fourth epoch that can be migrated onto: a host that dropped every earlier version. */ +const EPOCH_V4_MIGRATING: ContentRegistry = (() => { + const base = epochRegistry("4", { "scene.v4": "The rebuilt hall.", "message.v4": "The rebuilt clerk nods." }); + const campaign: Campaign = { + ...base.campaigns.get("test-campaign")!, + migrateState: (kindState) => ({ ok: true, value: kindState, errors: [], warnings: [] }), + }; + return { campaigns: new Map([["test-campaign", campaign]]), strings: base.strings }; +})(); + +/** A save on v1 (`[a0]`) and a save on v2 that crossed an adoption (`[a0, c1 1→2]`). */ +async function savesOnOldEpochs(shared: ReturnType): Promise<{ uncrossed: string; crossed: string }> { + const channel = recordingChannel(undefined); + const { store } = makeEpochStore({ channel, persistence: shared.persistence, recordIds: prefixedRecordIds("source") }); + const plain = await store.createSession({ campaignId: "test-campaign" }); + await store.submitAction(plain.sessionId, "increment"); + const uncrossed = (await store.saveGame(plain.sessionId)).saveId; + + const moved = await store.createSession({ campaignId: "test-campaign" }); + channel.offer = "2"; + await store.submitAction(moved.sessionId, "increment"); + const crossed = (await store.saveGame(moved.sessionId)).saveId; + return { uncrossed, crossed }; +} + +describe("S134.1 — a save on a held epoch loads as it was made", () => { + it("loads on its own epoch with no migration, keeps replayCompatible, and branches", async () => { + const shared = makeSharedRows(); + const { uncrossed } = await savesOnOldEpochs(shared); + const { store } = makeEpochStore({ + persistence: shared.persistence, + initial: EPOCH_V2, + publish: [EPOCH_V1], + recordIds: prefixedRecordIds("later"), + }); + + const loaded = await store.loadGame(uncrossed); + expect(loaded.scene.body.textKey).toBe("scene.v1"); + const row = shared.rows.get(loaded.sessionId); + expect(row!.replayCompatible).toBe(true); + const state = storedState(row); + expect(state.campaignVersion).toBe("1"); + expect(state.formatVersion).toBe(1); + expect(state.actionLog).toEqual([{ seq: 0, actionId: "increment" }]); + await expect(store.branchSession(loaded.sessionId, 1)).resolves.toMatchObject({ scene: { body: { textKey: "scene.v1" } } }); + }); + + it("then reaches its adoption point, persisted in the new session's first write", async () => { + const shared = makeSharedRows(); + const { uncrossed } = await savesOnOldEpochs(shared); + const channel = recordingChannel("2"); + const { store } = makeEpochStore({ + channel, + persistence: shared.persistence, + initial: EPOCH_V2, + publish: [EPOCH_V1], + recordIds: prefixedRecordIds("later"), + }); + const before = shared.sessionPuts; + + const loaded = await store.loadGame(uncrossed); + expect(shared.sessionPuts - before).toBe(1); + expect(channel.calls).toEqual([{ campaignId: "test-campaign", sessionId: loaded.sessionId }]); + expect(loaded.scene.body.textKey).toBe("scene.v2"); + const row = shared.rows.get(loaded.sessionId); + expect(row!.revision).toBe(0); + expect(row!.replayCompatible).toBe(true); + const state = storedState(row); + expect(state.campaignVersion).toBe("2"); + expect(state.formatVersion).toBe(2); + expect(state.actionLog).toEqual([ + { seq: 0, actionId: "increment" }, + { seq: 1, system: "content", from: "1", to: "2" }, + ]); + }); + + it("an offer the kind refuses leaves the loaded session pinned on its own epoch", async () => { + const shared = makeSharedRows(); + const { uncrossed } = await savesOnOldEpochs(shared); + const { store } = makeEpochStore({ + channel: recordingChannel("3"), + persistence: shared.persistence, + initial: EPOCH_V2, + publish: [EPOCH_V1, EPOCH_V3_REFUSED], + recordIds: prefixedRecordIds("later"), + }); + + const loaded = await store.loadGame(uncrossed); + expect(loaded.scene.body.textKey).toBe("scene.v1"); + const state = storedState(shared.rows.get(loaded.sessionId)); + expect(state.campaignVersion).toBe("1"); + expect(state.actionLog).toHaveLength(1); + }); +}); + +describe("S134.2 — every other save migrates, and a restamp over a crossed log is logged", () => { + const migratingStore = (shared: ReturnType) => + makeEpochStore({ persistence: shared.persistence, initial: EPOCH_V4_MIGRATING, publish: [], recordIds: prefixedRecordIds("later") }); + + it("a crossed save onto a version it never held appends one migration entry, keeps formatVersion 2, and deserializes", async () => { + const shared = makeSharedRows(); + const { crossed } = await savesOnOldEpochs(shared); + const { store, engine } = migratingStore(shared); + + const loaded = await store.loadGame(crossed); + expect(loaded.scene.body.textKey).toBe("scene.v4"); + const row = shared.rows.get(loaded.sessionId); + expect(row!.replayCompatible).toBe(false); + expect(engine.deserialize(row!.blob).ok).toBe(true); + const state = storedState(row); + expect(state.campaignVersion).toBe("4"); + expect(state.formatVersion).toBe(2); + expect(state.actionLog).toEqual([ + { seq: 0, actionId: "increment" }, + { seq: 1, system: "content", from: "1", to: "2" }, + { seq: 2, system: "migration", from: "2", to: "4" }, + ]); + + // Saved and loaded again, it is already on the registry's version: no second entry. + const again = await store.loadGame((await store.saveGame(loaded.sessionId)).saveId); + const reloaded = storedState(shared.rows.get(again.sessionId)); + expect(reloaded.actionLog.filter((entry) => entry.system === "migration")).toHaveLength(1); + expect(reloaded.actionLog).toEqual(state.actionLog); + expect(shared.rows.get(again.sessionId)!.replayCompatible).toBe(false); + }); + + it("an uncrossed save migrates with no entry and stays formatVersion 1", async () => { + const shared = makeSharedRows(); + const { uncrossed } = await savesOnOldEpochs(shared); + const { store } = migratingStore(shared); + + const loaded = await store.loadGame(uncrossed); + const state = storedState(shared.rows.get(loaded.sessionId)); + expect(state.campaignVersion).toBe("4"); + expect(state.formatVersion).toBe(1); + expect(state.actionLog).toEqual([{ seq: 0, actionId: "increment" }]); + }); + + it("a migration that changes only kindVersion appends nothing", async () => { + const shared = makeSharedRows(); + const { crossed } = await savesOnOldEpochs(shared); + const kind: Kind = { + ...makeEpochKind(), + version: "2.0.0", + migrateState: (oldState) => ({ ok: true, value: oldState as TestKindState, errors: [], warnings: [] }), + }; + const { store } = makeEpochStore({ + kind, + persistence: shared.persistence, + initial: EPOCH_V2, + publish: [EPOCH_V1], + recordIds: prefixedRecordIds("later"), + }); + + const loaded = await store.loadGame(crossed); + const row = shared.rows.get(loaded.sessionId); + expect(row!.replayCompatible).toBe(false); + const state = storedState(row); + expect(state.campaignVersion).toBe("2"); + expect(state.formatVersion).toBe(2); + expect(state.actionLog).toEqual([ + { seq: 0, actionId: "increment" }, + { seq: 1, system: "content", from: "1", to: "2" }, + ]); + }); +}); + +/** A session that moved onto v2 between two actions: `[a0, c1 1→2, a2]`, with the stored blob + * after the adoption and after the last action. */ +async function crossedSource(shared: ReturnType) { + const channel = recordingChannel(undefined); + const { store } = makeEpochStore({ channel, persistence: shared.persistence, recordIds: prefixedRecordIds("source") }); + const { sessionId } = await store.createSession({ campaignId: "test-campaign" }); + channel.offer = "2"; + await store.submitAction(sessionId, "increment"); + const adoptedBlob = shared.rows.get(sessionId)!.blob; + channel.offer = undefined; + await store.submitAction(sessionId, "increment"); + const finalBlob = shared.rows.get(sessionId)!.blob; + return { store, channel, sessionId, adoptedBlob, finalBlob }; +} + +/** What a session that never adopted stores after `increments` actions — on v1, from the same + * counting ids, so the same gameId and seed. */ +async function neverAdoptedBlob(increments: number): Promise { + const shared = makeSharedRows(); + const { store } = makeEpochStore({ persistence: shared.persistence }); + const { sessionId } = await store.createSession({ campaignId: "test-campaign" }); + for (let i = 0; i < increments; i += 1) await store.submitAction(sessionId, "increment"); + return shared.rows.get(sessionId)!.blob; +} + +describe("S134.3 / S134.5 — a branch replays across an adoption, byte-identically", () => { + it("every fork point reproduces the source's prefix exactly, either side of the content entry", async () => { + const shared = makeSharedRows(); + const { store, sessionId, adoptedBlob, finalBlob } = await crossedSource(shared); + const blobAt = async (n: number) => shared.rows.get((await store.branchSession(sessionId, n)).sessionId)!.blob; + + expect(await blobAt(3)).toBe(finalBlob); + expect(await blobAt(2)).toBe(adoptedBlob); + // A fork before the adoption starts, and stays, on the old epoch. + expect(await blobAt(1)).toBe(await neverAdoptedBlob(1)); + expect(await blobAt(0)).toBe(await neverAdoptedBlob(0)); + }); + + it("a branch reaches its own adoption point in its first write", async () => { + const shared = makeSharedRows(); + const { store, channel, sessionId, adoptedBlob } = await crossedSource(shared); + channel.offer = "2"; + const before = shared.sessionPuts; + + const branch = await store.branchSession(sessionId, 1); + expect(shared.sessionPuts - before).toBe(1); + expect(branch.scene.body.textKey).toBe("scene.v2"); + expect(shared.rows.get(branch.sessionId)!.blob).toBe(adoptedBlob); + }); + + it("atActionCount counts entries of every type", async () => { + const shared = makeSharedRows(); + const { store, sessionId } = await crossedSource(shared); + await expect(store.branchSession(sessionId, 4)).rejects.toMatchObject({ operation: "branchSession", code: "invalid_fork_point" }); + }); +}); + +describe("S134.4 — a branch that cannot be reproduced refuses, with nothing written", () => { + it("invalid_state for a log carrying a migration entry, whatever the record says", async () => { + const shared = makeSharedRows(); + const { store, engine } = makeEpochStore({ persistence: shared.persistence }); + const created = engine.createGame({ campaignId: "test-campaign", seed: "s" }, "2"); + const migrated = { + ...created.value!, + formatVersion: 2, + actionLog: [{ seq: 0, system: "migration" as const, from: "1", to: "2" }], + }; + shared.rows.set("migrated", { + sessionId: "migrated", + blob: engine.serialize(migrated), + audience: "player", + attemptCounter: 0, + revision: 0, + replayCompatible: true, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + }); + + await expect(store.branchSession("migrated", 0)).rejects.toMatchObject({ operation: "branchSession", code: "invalid_state" }); + expect(shared.sessionPuts).toBe(0); + }); + + it("invalid_state for a recorded adoption the kind now refuses; a fork before it still branches", async () => { + const shared = makeSharedRows(); + const { sessionId } = await crossedSource(shared); + const refusing: Kind = { ...makeEpochKind(), adoptContent: () => ({ adopt: false, reason: "content_incompatible" }) }; + const { store } = makeEpochStore({ kind: refusing, persistence: shared.persistence, recordIds: prefixedRecordIds("later") }); + const before = shared.sessionPuts; + + await expect(store.branchSession(sessionId, 2)).rejects.toMatchObject({ operation: "branchSession", code: "invalid_state" }); + expect(shared.sessionPuts).toBe(before); + await expect(store.branchSession(sessionId, 1)).resolves.toMatchObject({ scene: { body: { textKey: "scene.v1" } } }); + }); + + it("unknown_campaign when the starting version is no longer held", async () => { + const shared = makeSharedRows(); + const { sessionId } = await crossedSource(shared); + const { store } = makeEpochStore({ persistence: shared.persistence, initial: EPOCH_V2, publish: [], recordIds: prefixedRecordIds("later") }); + const before = shared.sessionPuts; + + await expect(store.branchSession(sessionId, 3)).rejects.toMatchObject({ operation: "branchSession", code: "unknown_campaign" }); + await expect(store.branchSession(sessionId, 0)).rejects.toMatchObject({ operation: "branchSession", code: "unknown_campaign" }); + expect(shared.sessionPuts).toBe(before); + }); + + it("unknown_campaign when a retained adoption's version is no longer held; a fork before it still branches", async () => { + const shared = makeSharedRows(); + const channel = recordingChannel(undefined); + const source = makeEpochStore({ channel, persistence: shared.persistence, recordIds: prefixedRecordIds("source") }); + const { sessionId } = await source.store.createSession({ campaignId: "test-campaign" }); + channel.offer = "2"; + await source.store.submitAction(sessionId, "increment"); + channel.offer = "1"; + await source.store.submitAction(sessionId, "increment"); + expect(storedState(shared.rows.get(sessionId)).actionLog.map((entry) => entry.to ?? entry.actionId)).toEqual([ + "increment", + "2", + "increment", + "1", + ]); + + const { store } = makeEpochStore({ persistence: shared.persistence, initial: EPOCH_V1, publish: [], recordIds: prefixedRecordIds("later") }); + const before = shared.sessionPuts; + await expect(store.branchSession(sessionId, 4)).rejects.toMatchObject({ operation: "branchSession", code: "unknown_campaign" }); + await expect(store.branchSession(sessionId, 2)).rejects.toMatchObject({ operation: "branchSession", code: "unknown_campaign" }); + expect(shared.sessionPuts).toBe(before); + await expect(store.branchSession(sessionId, 1)).resolves.toMatchObject({ scene: { body: { textKey: "scene.v1" } } }); + }); +}); diff --git a/src/engine/src/core/session/store.ts b/src/engine/src/core/session/store.ts index c08b6c5..ea463d3 100644 --- a/src/engine/src/core/session/store.ts +++ b/src/engine/src/core/session/store.ts @@ -990,11 +990,13 @@ function createStore(options: InMemorySessionStoreOptions): SessionStore { return runExclusive(sessionLocks, sessionId, () => withCommand(sessionId, record.attemptCounter, async (decoratedEngine) => { const state = mustDeserialize(decoratedEngine, record.blob); - const campaign = registry.campaigns.get(state.campaignId); + // The epoch the state is on, so the envelope names the version a later load + // resolves it from (16 §7) — the registry's only while the session never moved. + const campaign = decoratedEngine.content.resolve(state.campaignId, state.campaignVersion)?.campaigns.get(state.campaignId); const kind = kinds[state.kindId]; if (!campaign || !kind) { // Defensive, same class as mustDeserialize's own throw above: a state this - // engine just resolved (deserializeState checks both campaignId and kindId) + // engine just resolved (deserializeState checks its epoch and kindId) // cannot fail either lookup except through store corruption. throw new Error("session store: saveGame — resolved state's campaign or kind is missing from the registry"); } @@ -1025,7 +1027,7 @@ function createStore(options: InMemorySessionStoreOptions): SessionStore { const sessionId = newSessionId(recordIds); return withCommand(sessionId, 0, async (decoratedEngine) => { - const resolution = resolveSaveEnvelope(save.blob, kinds, registry); + const resolution = resolveSaveEnvelope(save.blob, kinds, registry, (campaignId, version) => decoratedEngine.content.resolve(campaignId, version)); if (!resolution.ok) { // No CommandResult channel on SaveHandle/SessionHandle to report this through — // same reasoning as createSession's throw above (plan 14, Design item 1). @@ -1035,7 +1037,7 @@ function createStore(options: InMemorySessionStoreOptions): SessionStore { // event emission every other state entering a session goes through, rather than // envelope.ts's own checks (necessarily narrower: they only need enough to compare // versions) standing in as a second, parallel guarantee. - const state = mustDeserialize(decoratedEngine, decoratedEngine.serialize(resolution.state)); + const loaded = mustDeserialize(decoratedEngine, decoratedEngine.serialize(resolution.state)); // The saved audience and profileId both round-trip through SaveRecord (set in // saveGame above), never through the serialized envelope — a session created with // audience: "ai" must still be "ai" after save/load, and a profiled session must @@ -1043,7 +1045,7 @@ function createStore(options: InMemorySessionStoreOptions): SessionStore { const now = clock.now(); const record: SessionRecord = { sessionId, - blob: decoratedEngine.serialize(state), + blob: "", audience: save.audience, attemptCounter: 0, revision: 0, @@ -1052,6 +1054,9 @@ function createStore(options: InMemorySessionStoreOptions): SessionStore { updatedAt: now, ...(save.profileId !== undefined ? { profileId: save.profileId } : {}), }; + // 16 §5.3: the adoption point, persisted in the new session's first write. + const state = adoptOffered(decoratedEngine, loaded, record); + record.blob = decoratedEngine.serialize(state); await writeSession(record); cacheSession(sessionId, record); return { sessionId, scene: decoratedEngine.scene(state) }; @@ -1087,7 +1092,8 @@ function createStore(options: InMemorySessionStoreOptions): SessionStore { /** * §7.4. A lifecycle operation, not a resolution — it never reaches a `Kind` and never * appears in `{ seed, actionLog }` (A1). The branch is replayed, not copied: a fresh - * game is created from the source's `{ campaignId, seed }` and its `gameId` is then + * game is created from the source's `{ campaignId, seed }` on the log's starting + * version (16 §5.6) and its `gameId` is then * pinned to the source's own, because `gameId` is opaque, serialized data the engine * never parses, compares, or derives from (§2) — overwriting it after creation is * exactly what a pinned `IdSource.newGameId` would have produced (§7.4, "Reproducing a @@ -1100,33 +1106,47 @@ function createStore(options: InMemorySessionStoreOptions): SessionStore { throw new SessionStoreErrorValue("branchSession", "invalid_state"); } const sourceState = mustDeserialize(engine, source.blob); - // A log that crossed an epoch starts on another version and replays its content entries - // through `adoptContent`; until branching does that (S134), it refuses with nothing - // written rather than replaying a crossed log onto the default epoch. - const actions = sourceState.actionLog.filter(isLoggedAction); - if (actions.length !== sourceState.actionLog.length) { + const log = sourceState.actionLog; + // 16 §3.5: a migration entry implies a migrated load, which no replay can perform. The + // record already refuses above wherever one exists; this makes it readable from the log. + if (log.some((entry) => isEpochEntry(entry) && entry.system === "migration")) { throw new SessionStoreErrorValue("branchSession", "invalid_state"); } - if (!Number.isInteger(atActionCount) || atActionCount < 0 || atActionCount > sourceState.actionLog.length) { + // `atActionCount` counts entries of every type, so a fork may fall either side of an + // adoption (16 §5.6). + if (!Number.isInteger(atActionCount) || atActionCount < 0 || atActionCount > log.length) { throw new SessionStoreErrorValue("branchSession", "invalid_fork_point"); } - const campaign = registry.campaigns.get(sourceState.campaignId); - if (!campaign || campaign.version !== sourceState.campaignVersion) { + const retained = log.slice(0, atActionCount); + const startVersion = startingCampaignVersion(log, sourceState.campaignVersion)!; + // Every epoch the replay stands on — the start and each retained adoption's target — + // must still be held; checked before anything is created, so a refusal writes nothing. + const named = [startVersion, ...retained.filter(isEpochEntry).map((entry) => entry.to)]; + const isHeld = (version: string): boolean => + engine.content.resolve(sourceState.campaignId, version)?.campaigns.get(sourceState.campaignId)?.version === version; + if (!named.every(isHeld)) { throw new SessionStoreErrorValue("branchSession", "unknown_campaign"); } const branchSessionId = newSessionId(recordIds); - const retained = actions.slice(0, atActionCount); return withCommand(branchSessionId, 0, async (decoratedEngine) => { - const created = decoratedEngine.createGame({ campaignId: sourceState.campaignId, seed: sourceState.seed }); + const created = decoratedEngine.createGame({ campaignId: sourceState.campaignId, seed: sourceState.seed }, startVersion); if (!created.ok || !created.value) { - // Defensive, same class as mustDeserialize's own throw above: the campaign was - // just resolved from the registry by the same id/version above. + // Defensive, same class as mustDeserialize's own throw above: the starting epoch + // was just resolved by the same id/version above. throw new Error("session store: branchSession — createGame rejected for an already-validated campaign"); } let state: GameState = { ...created.value, gameId: sourceState.gameId }; for (const logged of retained) { + if (!isLoggedAction(logged)) { + // 16 §5.6: a recorded adoption must adopt again. One the kind now refuses means + // the prefix cannot be reproduced, and nothing has been written yet. + const adoption = decoratedEngine.adoptContent(state, logged.to); + if (!adoption.adopted) throw new SessionStoreErrorValue("branchSession", "invalid_state"); + state = adoption.state; + continue; + } const result = decoratedEngine.submitAction(state, logged.actionId, logged.params); if (!result.ok || !result.value) { // Defensive: every entry in actionLog was accepted once already (04 §4 only @@ -1140,7 +1160,7 @@ function createStore(options: InMemorySessionStoreOptions): SessionStore { const now = clock.now(); const record: SessionRecord = { sessionId: branchSessionId, - blob: decoratedEngine.serialize(state), + blob: "", audience: source.audience, attemptCounter: 0, revision: 0, @@ -1149,6 +1169,10 @@ function createStore(options: InMemorySessionStoreOptions): SessionStore { updatedAt: now, ...(source.profileId !== undefined ? { profileId: source.profileId } : {}), }; + // 16 §5.3: the branch's own adoption point, persisted in its first write. A branch + // taken before an adoption starts on the old epoch and is offered the new one here. + state = adoptOffered(decoratedEngine, state, record); + record.blob = decoratedEngine.serialize(state); await writeSession(record); cacheSession(branchSessionId, record); return { sessionId: branchSessionId, scene: decoratedEngine.scene(state) };