Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion design/30-slices.md
Original file line number Diff line number Diff line change
Expand Up @@ -470,7 +470,7 @@ Out of scope: Capture refusing a `replayCompatible: false` session (capture is u

## S135 — The Replay Oracle Crosses Content Epochs

Status: todo
Status: done
Delivers: A recorded play session that moved onto new content partway through is a regression
fixture like any other. A later engine version replays its adoptions in place, and
reports a divergence if the kind would now refuse one.
Expand Down
25 changes: 25 additions & 0 deletions design/90-decisions.md
Original file line number Diff line number Diff line change
Expand Up @@ -2691,6 +2691,31 @@ Context: S130 added a test that `branchSession` refuses a crossed log with `inva
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-09 — S135: withdrawal is read from the registry, every named version from the archive
Context: 07 §6 widens `campaign_version_missing` to any version a fixture names, the starting `campaignVersion` and each adoption's. `campaign_withdrawn` still means the id is gone, and a `ResolutionArchive` resolves `(id, version)` pairs but cannot enumerate ids.
Chosen: the runner's pre-check reports `campaign_withdrawn` when the runner context's registry lacks the id. Otherwise every named version must resolve through `engine.content.resolve`, to a campaign carrying that exact version, as the kernel's own epoch resolution requires. Any miss is `campaign_version_missing`, before `createGame` runs.
Rejected: **Deciding withdrawal from the archive.** It would need an enumeration the archive does not offer, or a probe of some version, and either would put a second definition of "the id is gone" beside the registry's.

### 2026-10-09 — S135: an adoption naming the version the game is already on is a malformed fixture
Context: `adoptContent`'s step 0 returns the state unchanged, with no log entry, when the target is the version the game is on. An `AdoptionDecision` for it would have no `seq`, and would read as accepted although nothing happened.
Chosen: the runner throws on such a submission, as it already throws on a missing seed or a `createGame` rejection. Capture records only adoptions that happened (16 §5.6), so the shape cannot arise from a recorded session.
Rejected: **Recording it as accepted with `seq: null`.** `seq: null` means refused everywhere else in `Outcome`, and the fixture would stay silently wrong.

### 2026-10-09 — S135: the replay types and helpers ship on `/authoring`, not `src/index.ts`
Context: the slice's *Touches* names `src/index.ts`. The replay types were already exported from the `/authoring` subpath (20-contract §19), and the new `ActionSubmission`, `AdoptionSubmission`, `ActionDecision` and `AdoptionDecision` belong beside them.
Chosen: `src/authoring.ts` exports the four new types beside `Submission`, `Decision` and `Outcome`. `src/index.ts` is unchanged.
Rejected: **Exporting them from the package root.** It would split one family across two entry points, against §19.

### 2026-10-09 — S135: corpus suites that play a fixture directly read its actions through `actionSubmissions`
Context: several suites and `scripts/capture-save-fixtures.ts` play a fixture's submissions through `submitAction` or a client, without the runner. A `Submission` can now be an adoption, which those paths cannot replay.
Chosen: `replay-corpus.ts` exports `actionSubmissions(fixture)`, which returns the action submissions and throws on an adoption, and `isAdoption`. The direct-play suites use the first, so a crossing fixture in their prefix fails loudly. `reasonCompletenessGuard` skips adoptions, since an adoption is not play (C24). `statesThatFailToDeserialize` creates the game on the fixture's `campaignVersion` and adopts through `engine.adoptContent`.
Rejected: **Filtering adoptions out silently everywhere.** A suite would then replay a crossing fixture as if it never crossed, and compare against an outcome it cannot reach.

### 2026-10-09 — S135: the corpus's crossing fixture adopts an epoch derived in the test, with its own `migrateState`
Context: S135.4 needs a corpus fixture that crosses an epoch, run against an archive holding both. Bureaucracy ships one epoch, 2.0.0. Adoption runs the *target* campaign's `migrateState` from the source version, and the shipped one migrates only from 1.0.0, so an epoch that inherited it would be refused with `migration_failed`.
Chosen: `bureaucracy-epoch-crossing` starts on 2.0.0, plays `wait`, adopts 2.1.0, then plays `registry_route_listen`. The bureaucracy replay suite derives 2.1.0 from the built campaign by adding one int variable, purely additive, with a `migrateState` that passes a 2.0.0 state through unchanged and delegates any other version to the shipped one. It publishes 2.1.0 into the engine's archive; the registry, and so every other fixture's start, stays 2.0.0.
Rejected: **Shipping a 2.1.0 bureaucracy.** It would change content to test the oracle. **Clearing `migrateState` on the derived epoch.** It would drop the 1.0.0 save path the shipped campaign carries.

### 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.
Expand Down
2 changes: 1 addition & 1 deletion docs/docs/engine/TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -464,7 +464,7 @@ Out of scope: Capture refusing a `replayCompatible: false` session (capture is u

## S135 — The Replay Oracle Crosses Content Epochs

Status: todo
Status: done
Delivers: A recorded play session that moved onto new content partway through is a regression
fixture like any other. A later engine version replays its adoptions in place, and
reports a divergence if the kind would now refuse one.
Expand Down
20 changes: 20 additions & 0 deletions src/engine/fixtures/replay/bureaucracy-epoch-crossing.fixture.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
{
"name": "bureaucracy-epoch-crossing",
"config": {
"campaignId": "bulgaria-bureaucracy",
"seed": "bureaucracy-seed-1"
},
"campaignVersion": "2.0.0",
"capturedUnder": "0.11.0",
"submissions": [
{
"actionId": "wait"
},
{
"adopt": "2.1.0"
},
{
"actionId": "registry_route_listen"
}
]
}
32 changes: 32 additions & 0 deletions src/engine/fixtures/replay/bureaucracy-epoch-crossing.outcome.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
{
"finalStatus": "active",
"acceptedActions": 2,
"decisions": [
{
"index": 0,
"seq": 0,
"actionId": "wait",
"accepted": true
},
{
"index": 1,
"seq": 1,
"adopt": "2.1.0",
"accepted": true
},
{
"index": 2,
"seq": 2,
"actionId": "registry_route_listen",
"accepted": true
}
],
"achievements": [
"found_registry_route_event_1a"
],
"terminal": {
"terminal": false,
"terminalId": null,
"endingId": null
}
}
4 changes: 2 additions & 2 deletions src/engine/scripts/capture-save-fixtures.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import { describe, it, expect } from "vitest";
import { createEngine } from "../src/core/kernel/engine.js";
import { createInMemorySessionStore } from "../src/core/session/store.js";
import { TextClient } from "../src/clients/text/client.js";
import { loadFixture } from "../src/campaigns/replay-corpus.js";
import { actionSubmissions, loadFixture } from "../src/campaigns/replay-corpus.js";
import { SessionStoreError } from "../src/core/session/types.js";
import type { SaveRecordStore, SessionRecordStore, StoredSaveRecord, StoredSessionRecord } from "../src/core/session/types.js";
import { SAVE_FIXTURES, buildRegistry } from "./capture-save-fixtures.js";
Expand Down Expand Up @@ -65,7 +65,7 @@ describe("W104.3 — representative saves load through a real SessionStore", ()
const independentClient = new TextClient(independentStore);
const started = await independentClient.createSession(fixture.config);
const sessionId = started.value.sessionId;
for (const submission of fixture.submissions) {
for (const submission of actionSubmissions(fixture)) {
await independentClient.submitAction(sessionId, submission.actionId, submission.params);
}
const expectedScene = (await independentClient.getScene(sessionId)).value;
Expand Down
4 changes: 2 additions & 2 deletions src/engine/scripts/capture-save-fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import { mkdirSync, writeFileSync } from "node:fs";
import { createEngine } from "../src/core/kernel/engine.js";
import { buildValidatedContentRegistry } from "../src/core/validation/tiered.js";
import { buildSaveEnvelope, serializeSaveEnvelope } from "../src/core/persistence/envelope.js";
import { loadFixture } from "../src/campaigns/replay-corpus.js";
import { actionSubmissions, loadFixture } from "../src/campaigns/replay-corpus.js";
import { buildBulgariaBureaucracyCampaign } from "../src/campaigns/bulgaria-bureaucracy.js";
import { buildStableLifeCampaign } from "../src/campaigns/stable-life.js";
import { buildStableLifeEffectsCampaign } from "../src/campaigns/stable-life-effects.js";
Expand Down Expand Up @@ -69,7 +69,7 @@ function runFixtureToState(engine: Engine, fixtureName: string): GameState {
const created = engine.createGame(fixture.config);
if (!created.ok || !created.value) throw new Error(`"${fixtureName}": createGame rejected`);
let state = created.value;
for (const submission of fixture.submissions) {
for (const submission of actionSubmissions(fixture)) {
const result = engine.submitAction(state, submission.actionId, submission.params);
if (result.ok && result.value) state = result.value;
}
Expand Down
5 changes: 5 additions & 0 deletions src/engine/src/authoring.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,10 @@ const AUTHORING_VALUE_EXPORTS = [

const AUTHORING_TYPE_EXPORTS = [
"AchievementDefinitionSource",
"ActionDecision",
"ActionSubmission",
"AdoptionDecision",
"AdoptionSubmission",
"AdventureConfig",
"AdventureEnding",
"AdventureRoute",
Expand All @@ -59,6 +63,7 @@ const AUTHORING_TYPE_EXPORTS = [
"Condition",
"Consequence",
"CourseDefinitionSource",
"Decision",
"DifficultyDefinitionSource",
"EmployerDefinitionSource",
"EndingNodeSource",
Expand Down
5 changes: 5 additions & 0 deletions src/engine/src/authoring.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,11 @@ export type {
PortableMigration,
} from "./index.js";
export type {
ActionDecision,
ActionSubmission,
AdoptionDecision,
AdoptionSubmission,
Decision,
Outcome,
ReplayFixture,
ReplayVerdict,
Expand Down
50 changes: 48 additions & 2 deletions src/engine/src/campaigns/bulgaria-bureaucracy.replay.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,9 @@ import { buildValidatedContentRegistry } from "../core/validation/tiered.js";
import { createInMemoryProfileStore } from "../core/session/profile-store.js";
import { storyGraphKind } from "../kinds/story-graph/kind.js";
import { createCountingIds } from "../core/determinism/counting-ids.js";
import { createContentArchive } from "../core/registry/archive.js";
import type { BuiltCampaign } from "../core/registry/types.js";
import type { StoryGraphCampaign } from "../kinds/story-graph/campaign.js";
import { buildReplayOutcome, findDivergence, runReplayFixture, type ReplayRunnerContext } from "../core/replay/runner.js";
import type { Outcome, ReplayFixture } from "../core/replay/types.js";
import type { KindRegistry } from "../core/kernel/types.js";
Expand All @@ -62,18 +65,51 @@ const FIXTURE_NAMES = fixtureNamesByPrefix("bureaucracy-", CORPUS_DIR);
* `CURRENT_STABLE_LIFE_FIXTURE_NAMES` and `STABLE_LIFE_FIXTURE_NAMES`. */
const CURRENT_BUREAUCRACY_FIXTURE_NAMES = fixtureNamesByPrefix("bureaucracy-", FIXTURES_DIR);

/** The epoch `bureaucracy-epoch-crossing` adopts (S135.4): the shipped campaign plus one
* variable, purely additive, so story-graph's `adoptContent` accepts it (16 §6.1). Derived
* here rather than shipped: it exists to give the corpus a second held epoch, not as content.
* Adoption runs the *target's* `migrateState` from the source version, and the shipped one
* migrates only from 1.0.0 — so this epoch passes a shipped-epoch state through unchanged,
* as its author would, and leaves the new variable to `adoptContent`. */
const BUREAUCRACY_NEXT_EPOCH = "2.1.0";

function nextEpoch(built: BuiltCampaign): BuiltCampaign {
const content = built.campaign.content as StoryGraphCampaign;
const shipped = built.campaign.version;
const migrateShipped = built.campaign.migrateState;
return {
...built,
campaign: {
...built.campaign,
version: BUREAUCRACY_NEXT_EPOCH,
migrateState: (state, fromVersion) =>
fromVersion === shipped
? { ok: true, value: state, errors: [], warnings: [] }
: migrateShipped
? migrateShipped(state, fromVersion)
: { ok: false, errors: [{ code: "migration_failed", messageKey: "core.reason.migration_failed" }], warnings: [] },
content: { ...content, variables: { ...content.variables, forms_refiled: { type: "int", initial: 0 } } },
},
};
}

/** A fresh `ReplayRunnerContext` per call — each fixture gets its own counting `IdSource`
* starting at 0 (07 §5) and its own in-memory `ProfileStore`, so achievements from one
* fixture never leak into another's. */
* fixture never leak into another's. The engine's archive holds the shipped epoch and
* `BUREAUCRACY_NEXT_EPOCH`; the registry, and so every fixture's default, stays the shipped one. */
function makeContext(): ReplayRunnerContext {
const built = buildBulgariaBureaucracyCampaign();
if (!built.ok || !built.value) throw new Error("expected the real campaign to build");
const kinds = { "story-graph": storyGraphKind } as unknown as KindRegistry;
const registryResult = buildValidatedContentRegistry([built.value], kinds);
if (!registryResult.ok || !registryResult.value) throw new Error("expected the real campaign to validate");
const nextResult = buildValidatedContentRegistry([nextEpoch(built.value)], kinds);
if (!nextResult.ok || !nextResult.value) throw new Error("expected the next epoch to validate");
const archive = createContentArchive({ kinds, initial: registryResult.value });
if (!archive.publish(nextResult.value).ok) throw new Error("expected the next epoch to publish");

return {
engine: createEngine({ kinds, registry: registryResult.value, ids: createCountingIds() }),
engine: createEngine({ kinds, registry: registryResult.value, ids: createCountingIds(), archive }),
kinds,
registry: registryResult.value,
profiles: createInMemoryProfileStore(),
Expand Down Expand Up @@ -173,6 +209,16 @@ describe.skipIf(COMPARING_ACROSS_VERSIONS)("the replay corpus's mechanics (07-re
expect(verdict).toEqual({ kind: "unrunnable", reason: "campaign_version_missing" });
});

it("bureaucracy-epoch-crossing: the adoption is accepted at its own seq and counts in decisions, not acceptedActions (S135.4)", async () => {
const fixture = loadFixture("bureaucracy-epoch-crossing");
const result = await buildReplayOutcome(makeContext(), fixture);
if (result.kind !== "outcome") throw new Error("expected an outcome");

expect(result.outcome.decisions[1]).toEqual({ index: 1, seq: 1, adopt: BUREAUCRACY_NEXT_EPOCH, accepted: true });
expect(result.outcome.decisions[2]).toEqual({ index: 2, seq: 2, actionId: "registry_route_listen", accepted: true });
expect(result.outcome.acceptedActions).toBe(2);
});

it("findDivergence(expected, expected) is always undefined — a fixture never diverges from itself", async () => {
for (const name of FIXTURE_NAMES) {
const expected = loadExpectedOutcome(name);
Expand Down
Loading
Loading