Skip to main content

Core Specification

Document status: Revision 1 — the platform core, as types

Reading order: logically the core underlies the kinds; the filename is 04 only to avoid renumbering. Read after 02-architecture.md, before or alongside 03-story-graph-kind.md — which is the order the sidebar presents, stated in docs/sidebar.ts rather than taken from the filename prefix.

Scope of this document

The game-agnostic core, defined as types: the GameState envelope, the Kind interface (the seam every kind implements), the platform engine API, the session store, generic scenes/actions, projection, the content registry, tiered validation, reason codes, randomness, serialization/save/migration, the determinism harness, and the MCP tool schemas.

02-architecture made the decisions; this turns each into a type. Named ≠ defined ≠ buildable — that lesson, from games/, applied to the platform.

Reused, not re-derived. The seeded RNG (RngState, PCG32, deriveStream) and canonical serialization are already built and verified in src/engine/src/core/ (Engine Package), and were first specified in games/04-engine-specification.md §3, §2.1. This document references them and does not restate the algorithms.

What games/04-engine-specification.md is, and is not. It is a 104 KB engine specification in the companion game project, SubZeroDev.GameOfLife — the document this one was derived from. It is cited throughout these specs, and every such citation is provenance, not authority.

For anything the core owns — the engine API, randomness, save and serialization, testing, package layout, conditions, projections — this document supersedes it. Where the two disagree, this one is correct, and the older text should be read as the draft that led here rather than as a second opinion.

It is no longer authoritative for anything. It was, for one thing: the simulation kind's own content and resolution model (its §5, §7–§10, §12, §14), held upstream until a contract existed in this repository against the Kind seam (§3). That contract exists — 10-simulation-kind.md, whole as of its Revision 2, expressed against §3 the way 03-story-graph-kind.md is, with every type SimulationKindState names and every resolution mechanic that dispatches on them specified here. Its §15 records what was ported and what each pass found.

What remains upstream is provisional balance, not contract: drift rates, scenario economics, demandBand thresholds, and the housing-quality formula, indexed in OPEN-QUESTIONS.md §2 as needing a balancing pass. Numbers, not shape — and a number this repository has not yet chosen is not a rule living somewhere else.


1. The Two Layers of "Engine"​

Two things get called "the engine." They are different, and the split is load-bearing.

  • The pure engine — a set of pure functions. f(state, action) → new state. No I/O, no session, no clock. Testable, replayable, deterministic. This is what the Kind interface and the reducers live in.
  • The session store — a thin stateful layer above the pure engine that holds serialized state blobs by id, so a client can resume (architecture §2). It does I/O; it holds no game logic.

The platform API (§7) is the session store's surface. Clients talk to it; it calls the pure engine.

1.1 Internal Modules​

The core is one public surface but several internal modules, each a single responsibility. This is code organization, not new API — a peer-review recommendation to keep the growing core maintainable. The src/engine/src/core/ layout mirrors it.

ModuleOwnsSection
kernelthe GameState envelope, the Engine, submitAction§2, §4
sessionthe session store, save/load handles, the profile store§7, §7.1
persistencecanonical serialize/deserialize, SaveEnvelope, migration§10
projectionthe project mechanism, audiences§9
validationthe tiered validator, ValidationResult§11
registrythe content registry, campaign resolution§10.1
localizationLocKey resolution against string tables§12, §17
determinismthe RNG handle, streams, the harness§8, §14
observabilitythe Emitter, EngineEvent, sinks05-observability.md
compositionthe host roots and the port interfaces06-extensibility.md

Kinds (kinds/) and clients (clients/, mcp/) sit above; the dependency arrow points only downward — a core module never imports a kind or client.


2. The GameState Envelope​

The core owns a kind-agnostic envelope and treats each kind's own state as an opaque payload inside it. This is the single most important type in the platform: it is what advance, serialize, and the session store operate on.

type KindId = "story-graph" | "simulation" | "world-graph";

interface GameState {
formatVersion: number; // the shape of THIS envelope — 1, or 2 once the log carries an epoch entry (§10.2)
gameId: string; // from the IdSource port (06 §5.1); opaque to the core

kindId: KindId;
campaignId: string;
campaignVersion: string; // the published version this game runs (§10)

seed: string; // the only randomness state — streams derive from it (§8)

status: GameStatus; // active | ended | abandoned
kindState: unknown; // the kind's own state — opaque to the core

actionLog: LoggedEntry[]; // ordered entries — the replay spine (§9)
}

type GameStatus = "active" | "ended" | "abandoned";

type LoggedEntry = LoggedAction | LoggedContent | LoggedMigration;

interface LoggedAction {
seq: number; // 0-based, monotonic across every entry type
actionId: string; // the action the player submitted
params?: Readonly<Record<string, string | number | boolean>>;
}

interface LoggedContent { // an adoption (16 §3.1)
seq: number; // consumes a seq, exactly like an action
system: "content";
from: string; // the campaignVersion before adoption
to: string; // the campaignVersion after — the last epoch entry's `to` IS campaignVersion
}

interface LoggedMigration { // a migrated load that changed campaignVersion on a log already carrying an epoch entry (§10.2; 16 §3.5)
seq: number; // consumes a seq, exactly like an action
system: "migration";
from: string; // the campaignVersion the save was made under
to: string; // the campaignVersion the migration restamped
}

The log records adoptions, and the starting epoch is derived from it. A game that adopts new content mid-play (§4, adoptContent; 16-content-epochs.md) appends a LoggedContent entry, so the replay input is { seed, starting version, actionLog }, where the starting version is the first epoch entry's from, or campaignVersion when the log carries none. It is not stored: a startingCampaignVersion field would duplicate what the log already says. Once a log carries an epoch entry, every change of campaignVersion is one — an adoption a LoggedContent, a migrated load a LoggedMigration (§10.2) — so the last epoch entry's to is always the current version. A migrated log with no epoch entry stays without one; its record's replayCompatible: false is what keeps it from being replayed. formatVersion is 2 exactly on a state whose log carries an epoch entry, and 1 otherwise — a game that never adopts serializes byte-identically to one written before content epochs existed, migrated or not (C26).

What lives here vs in kindState. The envelope holds everything a game has regardless of kind: identity, campaign reference, seed, status, and the action log. A kind's own concepts — current node, variables, turn counter, week number, needs — live in kindState, opaque to the core.

No persisted RNG state. Randomness is derived, not carried: every stream is a pure function of (seed, streamId) (§8), so the envelope stores the seed and nothing else. A persisted generator state would be written every action and read by nothing — a serialized field free to drift from the derivable truth, taking byte-identical replay with it. { seed, actionLog } is the complete replay input.

Why kindState: unknown. The core must not depend on any kind. Typing the field as unknown (not a union of kind states) keeps the dependency arrow pointing the right way — kinds depend on the core, never the reverse. Each kind casts its own kindState internally, guarded by kindId. This is the platform equivalent of the simulation kind's "engine imports no client" rule (games/04-engine-specification.md §20.1).

Determinism note. No wall-clock (createdAt/updatedAt) lives in GameState — that would make byte-identical replay impossible. Timestamps, if a host wants them, live in the session-store record (§7), outside the replayable state. The determinism guard in src/engine/eslint.config.js enforces no Date.now.


3. The Kind Interface — The Seam​

A kind is engine-owned code that teaches the core how one category of game plays. Every kind implements this interface; the core drives it without knowing which kind it is.

interface Kind<KState> {
readonly id: KindId;
readonly version: string; // manually maintained semver (§10.2, W31)
readonly reasonCodes: readonly ReasonCode[]; // codes this kind adds to the base set (§12)
/** The kind-owned half of the string table: registry assembly merges it alongside the
* core's protected `core.reason.*` set (§10.1), and validation checks it for completeness
* before assembly runs (§11, §12). A kind ships its own messages for the same reason the
* core ships the base set's — the codes are useless to a client that cannot render them.
*
* It must carry a `${id}.reason.<code>` entry for **every** member of `reasonCodes`; the
* completeness check is `registered → has a message` and nothing more. It may carry
* others, and this is a channel rather than a leak: a kind's own engine-created content
* can reference a `LocKey` no campaign collection exists to author — `simulation`'s
* `simulation.finance.investment.label`, on the fixed investment account its `invest`
* resolver creates — and `reasonMessages` is a `Kind`'s only route into the merged
* registry. Such a key is namespaced under the kind like any other and is not a reason
* code; nothing resolves it as one. */
readonly reasonMessages: ReadonlyMap<LocKey, string>;
readonly eventNames: readonly EventName[]; // events this kind may emit (05 §9)

/** Build the starting kind-state for a fresh game of this campaign. `profileData` is this
* kind's own cross-game slice, already resolved and migrated by the session store before
* the pure engine ran (§5, §7.1). It is absent for an anonymous session, for a kind that
* declares no `profileData` member, and for a replay whose `NewGameConfig` carries none —
* a kind that reads it must treat absence as "no cross-game history", never as an error. */
initialState(campaign: Campaign, ctx: KindContext, profileData?: unknown): InitialStateResult<KState>;

/** What the player can do right now — generic actions for the current scene (§6). Takes
* `ProjectionContext` (§3.1): an engine-owned `LocKey` it emits passes through
* `ctx.engineRef` at the point it is produced. */
availableActions(state: KState, ctx: ProjectionContext): AvailableAction[];

/** Render the current situation into a generic scene body (§6). Takes `ProjectionContext`
* for the same reason as `availableActions`. */
scene(state: KState, ctx: ProjectionContext): SceneBody;

/** Resolve one player action. Pure: same (state, action, params, ctx) → same result. */
advance(
state: KState,
actionId: string,
params: ActionParams | undefined,
ctx: KindContext,
): AdvanceResult<KState>;

/** Narrow kind-state to the visible projection for an audience (§9). May return values
* that alias `state`; the core copies at the boundary (§9.1). The one requirement is
* that the result be plain, structurally cloneable data. An engine-owned `LocKey` in the
* result passes through `ctx.engineRef` (§3.1). */
project(state: KState, audience: ProjectionAudience, ctx: ProjectionContext): unknown;

/**
* Tiered content validation of a campaign of this kind (§11). `strings` is the
* registry's built string table — checking a `LocKey` resolves, or that rendered text
* interpolates only a declared, visible variable, needs the table itself, not just the
* kind's opaque `content`.
*/
validateCampaign(campaign: Campaign, strings: ReadonlyMap<LocKey, string>): ValidationResult;

/**
* Whether a `kindState` arriving from outside the engine is one this kind can run against
* `campaign`. The core checks `kindState` for presence only — its shape is the kind's to
* state — so `deserialize` asks here after its own checks (§4), and the save boundary asks
* again after any migration (§10.2). Pure and total over `unknown`: same input, same
* answer, and a throw is read as `false`. Never consulted on a state the engine produced.
*/
validateState(kindState: unknown, campaign: Campaign): boolean;

/**
* A minimal, cross-version-stable terminal identity — published ids only, never
* values ([`07-replay.md`](07-replay.md) §3.3). Every kind returns at least
* `KindOutcome` (§3.2) and may widen it with its own published ids.
*/
outcome(state: KState): KindOutcome;

/**
* How many distinct `terminalId`s (§3.2) a campaign of this kind declares, for progress
* display (§7.3). Optional: a kind whose terminal set is not a bounded, countable
* property of its content omits it, and the core reports no total rather than a wrong
* one. Pure over content — it reads `campaign`, never state, and never a profile.
*/
terminalCount?(campaign: Campaign): number;

/**
* Migrates a `KState` produced under an older `version` forward, when this kind's own
* state shape changed (§10.2). Optional — most version bumps don't change the shape a
* save references. Invoked only at the save-load boundary (`SessionStore`), never by
* `advance`; a missing function on a version mismatch fails the load rather than
* silently handing this version a state it wasn't written to read.
*/
migrateState?(oldState: unknown, fromVersion: string): CommandResult<KState>;

/**
* Whether a running `state` can move from campaign `from` to campaign `to`, two published
* epochs of the same campaign id, and what it becomes (16 §5.4).
* Called by `Engine.adoptContent` after the target campaign's `migrateState` ran. Optional:
* **absent means every session of this kind stays pinned** to the epoch it started on, which
* is the safe default. Pure; reads neither the archive, the channel nor the log; draws no
* randomness. A throw is read as a refusal with `content_incompatible`.
*/
adoptContent?(state: KState, from: Campaign, to: Campaign): AdoptDecision<KState>;

/**
* This kind's cross-game profile slice (§7.1). **Absent means the kind owns no profile
* data at all** — no `KindProfileRecord` is ever written for it, `initialState` never
* receives a `profileData` argument, and nothing about profiles reaches it. That is the
* state every kind is in until it declares this member, and it is the reason the whole
* mechanism is one optional property rather than three correlated ones: a kind cannot
* declare a version without a fold, or a fold without a version.
*/
readonly profileData?: KindProfileData;

/**
* Merges included modules and pack attachments into a host campaign's content (§10.4).
* Optional: a kind without it cannot host an include or receive an attachment, and a
* campaign of that kind that declares either fails with `compose_unsupported`. Called by
* registry assembly **before** `validateCampaign`, once per host, with every module already
* composed depth-first — so it only ever merges one level. Pure over content: the result
* replaces `host.content` in the registry and is what validation and play both see.
*/
composeContent?(
host: Campaign,
modules: readonly ComposedModule[],
attachments: readonly ComposedAttachment[],
): CommandResult<unknown>;
}

/**
* The kind-owned half of §7.1's cross-game data. Engine-owned code, never a host-supplied
* port: it decides what a profile records and how a recorded value reads back, and 06 §2's
* rule admits a host only where it cannot change `serialize()` output. A seeded slice reaches
* `initialState`, so this one can.
*/
interface KindProfileData {
/** The shape version of this kind's `KindProfileRecord.data`. A positive integer, moving
* only when that shape changes. **Deliberately not `Kind.version`**: a kind's code moves
* far more often than its profile slice's shape does, and sharing the stamp would demand a
* profile migration for every unrelated bump. */
readonly version: number;

/**
* Fold one successful action's audit records into this kind's slice. Pure and total — same
* `(current, campaign, changes)` gives the same result, it performs no I/O, and it must not
* throw; the store treats a throw as a refused write (§7.1) rather than letting it reach the
* player, exactly as `resolveSaveEnvelope` already treats a throwing `migrateState`.
*
* **It must be idempotent**: folding the same `changes` twice must equal folding them once.
* That is what makes a transition survive a reload, a branch, or a re-submitted action
* without a duplicate entry, and it is why the recorded shapes below are maxima and sets
* rather than sums.
*
* `current` is `undefined` the first time this profile records anything for this kind.
* Returning a value whose canonical serialization equals `current`'s means "nothing to
* write", and the store skips the write — which is how idempotence becomes observable
* rather than merely claimed.
*/
fold(current: unknown, campaign: Campaign, changes: readonly StateChange[]): unknown;

/**
* Migrate a slice written under an older `version` (§7.1). Optional, and its absence is a
* *degradation* rather than a failure: a version mismatch with no migration drops the slice
* and warns, because a profile is a mirror of games already played and the game is the
* system of record. This is the one place a migration may be missing without failing —
* §10.2's save-side mismatch fails loudly, because there the payload *is* the record.
*/
migrate?(data: unknown, fromVersion: number): CommandResult<unknown>;
}

interface AdvanceResult<KState> {
state: KState; // the new kind-state
status: "active" | "ended"; // advance never yields "abandoned" — that is session-only (§7)
changes: StateChange[]; // audit records (§12) — for history and transparency
messages: OutcomeMessage[]; // player-facing, localized (§12)
error?: ValidationError; // set iff the action was rejected; state is unchanged
}

interface InitialStateResult<KState> {
state: KState; // the starting kind-state
status: "active" | "ended"; // a kind that settles at start may already be ended
changes: StateChange[];
messages: OutcomeMessage[];
}

type AdoptDecision<KState> =
| { adopt: true; state: KState } // the kind-state on the new epoch
| { adopt: false; reason: ReasonCode }; // stays pinned; a base or this kind's own code

Why AdoptDecision carries no changes or messages. Adoption is not play: nothing about it may reach the profile fold (§7.1) or claim a turn happened (C24). A kind that wants the player told renders it in the next scene from state, which the client re-renders anyway.

Why initialState returns a result, not a bare KState. A kind that settles at start (story-graph, 03 §8.2) can land on an ending before the player acts — a valid campaign (§11, Tier 2 warns). The core cannot discover this by inspecting kindState, which is unknown to it by design (§2), so the kind must say so. InitialStateResult is deliberately AdvanceResult minus error: a campaign is pre-validated before the registry is frozen, so starting a game cannot fail the way an action can.

Why advance receives params. submitAction writes params into the replay log (§2), so anything they affect must be reachable from the kind — otherwise the log carries data that provably cannot change replay. The story-graph kind declares no parameters (an action is a choice id) and returns a ValidationError if a non-empty params object arrives. Undocumented parameters are never silently ignored.

This holds for every kind, and a kind's contract is its declaration. Each kind's action table (03 §8.2, 10 §4, 12 §6) names the keys each actionId accepts. advance rejects a params object carrying any other key with the kind's own unexpected_params, before it reads any value. A rejected action is never logged (§4), so every logged action carries only declared keys, which is what lets 08-session-capture.md §3.2 copy logged params without filtering them. The declaration is not a member of Kind: a schema the core could read would be a second copy of the validator advance already is (12 §7).

A rejection also returns a message. error tells the core and client that the action failed and why (ReasonCode, messageKey); messages is what tells the player — a rejected AdvanceResult attaches one OutcomeMessage built from the same messageKey the error already carries ({ key: messageKey, visible: true }), so a rejection is never silently swallowed by a client that only renders messages. Every kind's rejection path follows this convention (world-graph's actions/common.ts rejected helper and the story-graph kind's own, §8.3).

advance is where a kind's whole ruleset lives. For the story-graph kind it is submitChoice → settle (03-story-graph-kind.md §8.2); for the simulation kind it is the weekly resolution (games/04-engine-specification.md). The core calls it and never looks inside.

One action model, three kinds. The core's action is a string actionId plus optional params. For the story-graph kind an action is a choice id, and it declares no params at all. For the simulation kind, actions map to its richer verbs (submit a plan, end the week). For the world-graph kind they are richer still — build carries a definition, a position and a rotation; advance_ticks carries a tick count (12-world-graph-kind.md §6). The core does not care — it forwards the actionId and the kind interprets it. This is what lets one API (§7) and one MCP surface (§13) serve all three, and the spread from no params to four is the evidence that the model scales rather than merely fitting the two it was drawn from.

3.1 KindContext​

Everything a kind needs to resolve, supplied by the core:

interface KindContext {
readonly registry: ContentRegistry; // §10 — the campaign and shared content
readonly campaign: Campaign; // this game's campaign, resolved
readonly rng: RngHandle; // handle on this resolution's own stream (§8)
readonly derive: (streamId: StreamId) => RngHandle; // any other stream, same seed (§8)
readonly seq: number; // current action sequence number
readonly emit: ResolutionEmitter; // this resolution's event handle (05 §4)
}

The kind draws randomness only from ctx.rng — a handle on the stream derived for this resolution from (seed, streamId) — or from ctx.derive, for the streams that are keyed by something other than the action. Either way the handle is discarded when advance returns; nothing is written back, because the next resolution derives its own stream from the seed again (§8). The kind stays pure and every draw stays reproducible.

Why derive exists. §8 defines four StreamId variants, but only a kind is ever in a position to use three of them — the core cannot know that a draw belongs to this guest's fifth decision rather than to the action in flight. Without derive, ctx.rng was the only reachable stream and those variants were unreachable by construction. derive closes over the game's seed and nothing else: it is pure, it persists nothing, and { seed, actionLog } remains the complete replay input. The kind that forced this is world-graph, whose correctness depends on draws keyed by simulated time rather than by how a client batched its requests (12-world-graph-kind.md §5).

ctx.emit is the same shape for the same reasons: a handle scoped to this resolution, used and discarded, carrying nothing back into state. It reports what the kind is doing to whatever sink the host attached, and emit returns void precisely so that nothing about the sink can reach the game (05-observability.md §2). Removing every event must leave serialize() byte-identical — the determinism harness asserts it (§14).

The three projecting members — availableActions, scene and project — receive a wider context; initialState, advance and migrateState never do:

interface ProjectionContext extends KindContext {
/** Maps an engine-owned `LocKey` to the key a response carries for it (§7, response
* aliases). The identity in production unless an authored key in the archive collides
* with an engine key; every engine-owned key under the forced-alias test context. */
readonly engineRef: (key: LocKey) => LocKey;
}

A kind calls engineRef at the point it produces an engine-owned reference — a world-graph.action.* label, a kind reasonMessages key, a story-graph requirement failure with no authored requirementFailKey — and never on a key that came from content or kindState. Aliasing by text match is not available, because the same string can be both (16 §5.7). The member's absence from KindContext is the enforcement of C29: an alias cannot reach state when nothing that writes state can produce one.

3.2 KindOutcome — Terminal Identity a Host Can Read​

Kind.outcome returned unknown, so a host holding a finished session could learn nothing from it without knowing which kind produced it. That is the one place the seam leaked its own purpose: terminal identity exists to be the cross-version-stable vocabulary (07-replay.md §3.3), and a vocabulary only one reader can parse is not one. The floor is now typed.

interface KindOutcome {
readonly terminal: boolean; // false while the game is still active
readonly terminalId: string | null; // the published id naming how it ended; null while active
}

terminalId is a published id, and terminal is not derivable from it being non-null — they are two facts, and a kind that ever ends without naming a terminal keeps them distinguishable rather than forcing null to mean two things. terminal is the field a host branches on; terminalId is the field it indexes, stores and compares.

Each shipped kind widens this rather than replacing it, so nothing already in a kind's outcome moves or is lost:

KindterminalId isWidened with
story-graphendingId (03 §8.5)endingId
simulationresolution — goals_met / failed / week_limit_reached (10 §12)resolution, goalsMet, goalsFailed
world-graphresolution — objectives_met / failed (12 §8)resolution, objectivesMet, failureId

The two columns are not one vocabulary, deliberately. A story-graph terminal is an authored id drawn from campaign content; a simulation or world-graph terminal is a declared id drawn from a closed set this contract fixes. Flattening them would mean either inventing authored resolutions the two mechanical kinds do not have, or collapsing every ending a campaign ships into three buckets. What a host actually needs is weaker and is what is guaranteed here: terminalId is stable across engine versions, unique within its campaign, and safe to persist and compare — which is enough to index a finished session, count distinct terminals reached (§7.3), and detect a replay divergence, none of which require knowing what the id means.

A host still may not read a widened field without knowing the kind. That is not a concession; it is the same rule as kindState (§2). The base is a floor, not a lowest common denominator that kinds are then discouraged from exceeding.

Balance-sensitive values remain excluded, unchanged. Nothing here loosens 07-replay.md §3.4: no money, no needs, no tick or week counts, no satisfaction score. KindOutcome adds a boolean and an id, both of which a rebalance leaves alone.

Why not a win/loss disposition on the base. It is the field a host wants next and it was deliberately left out. Kind.outcome receives KState and no campaign — the same constraint 10 §12 already records for weekLimit — and story-graph's win/loss lives on EndingNode (03-story-graph-kind.md §3), in content the function cannot reach. Supplying it would mean persisting the disposition into StoryGraphKindState, which is a kind- state shape change: a kindVersion bump and a Kind.migrateState (§10.2) for every existing save. That is a defensible unit of work and it is not this one. Recorded rather than dropped — a host that needs win/loss today reads StoryGraphView.ending.outcome (03 §9), which has carried it all along.


4. Registration and the Pure Engine​

Kinds are registered at engine construction — a fixed, engine-owned set (architecture §1). A missing kind is a construction error, not a runtime surprise.

type KindRegistry = Readonly<Record<KindId, Kind<unknown>>>;

function createEngine(host: EngineHost): Engine; // EngineHost — 06 §4

What this replaced, and why the shape changed. A three-positional-argument form — createEngine(registry, kinds, emitter?) — stood here until the IdSource port (06-extensibility.md §5.1) closed a real gap it left open: gameId and seed were consumed by createGame below with no named source. EngineHost { kinds, registry, ids?, emitter? } (06 §4) supplies all four in one shape, and each optional port has a working default — ids a random source, emitter nullEmitter (05-observability.md §4). The older form is recorded rather than deleted, the same "provenance, not authority" relationship this document has with games/04-engine-specification.md above, just one level down.

The pure engine exposes kind-agnostic operations over the envelope. It resolves the kind by state.kindId, derives the RNG handle, delegates, and reassembles the envelope:

interface Engine {
/** The same `KindRegistry` this engine resolves `state.kindId` against, exposed so a
* caller needing kind metadata outside gameplay — `SessionStore`'s `SaveEnvelope`
* stamping and migration dispatch (§10.2) — reads it off the one engine it already
* holds rather than taking a second, independently-suppliable registry that could
* silently disagree with what this engine actually plays against. */
readonly kinds: KindRegistry;
/** The archive this engine resolves every state's content through (06 §4,
* `EngineHost.archive`) — by default one holding only the construction-time registry.
* Exposed for the same reason as `kinds`: the session store and replay runner read the
* epoch the engine actually plays against, never a second copy. */
readonly content: ResolutionArchive;
/** `campaignVersion` absent → the construction-time registry's version for that campaign
* (the default epoch); present → that epoch, or `unknown_campaign`. `NewGameConfig` does
* not carry it: a version is not something a player chooses (07 §2). */
createGame(config: NewGameConfig, campaignVersion?: string): CommandResult<GameState>;
scene(state: GameState): Scene; // §6
view(state: GameState, audience: ProjectionAudience): PlayerView; // §9
availableActions(state: GameState): AvailableAction[];
submitAction(state: GameState, actionId: string, params?: ActionParams): ActionResult;
previewAction(state: GameState, actionId: string, params?: ActionParams): ActionResult;
serialize(state: GameState): string; // §10 (canonical)
deserialize(data: string): CommandResult<GameState>;
migrate(data: string): CommandResult<GameState>; // §10
/** Move a running state onto another published epoch of its campaign, or say why not
* (16 §5.4). Never a rejection: a refusal is a value, so the
* session store can complete the command it rides on. `campaignVersion` equal to the
* state's → `{ adopted: true, state }`, the same object: no entry, no event
* (`90-decisions.md`, 2026-10-09, S130). */
adoptContent(state: GameState, campaignVersion: string): AdoptionResult;

/** The same engine, with every event stamped for one command
* ([`05-observability.md`](05-observability.md) §6.1). The session store builds a
* short-lived decorator per command and swaps it in here, rather than the pure engine
* ever holding a clock or per-command context of its own. Listed here because this is
* the canonical `Engine` block; 05 §6.1 owns the reasoning. */
withEmitter(emitter: Emitter): Engine;
}

interface ResolutionArchive {
/** The registry the epoch `(campaignId, campaignVersion)` was published in, or undefined.
* One key names one content forever (C23). Pure from the engine's view: the same key
* answers the same registry, or `undefined` once the host has retired it under the
* retention rule (16 §6); a retired key never answers again. The engine caches no
* resolution, so retirement takes effect at the next call. */
resolve(campaignId: string, campaignVersion: string): ContentRegistry | undefined;
/** Every authored key held by any epoch in this archive — the scope response aliases are
* chosen against (§7). Optional: absent means the scope is the construction-time
* registry's authored keys. A host archive that `restore`s legacy epochs must supply it,
* or a restored collision is answered without an alias. */
authoredKeys?(): ReadonlySet<LocKey>;
}

type AdoptionResult =
| { adopted: true; state: GameState }
| { adopted: false; reason: ReasonCode };

/** The reference archive (16 §4.3): both archive and channel, in memory, append-only. */
function createContentArchive(options: {
kinds: KindRegistry;
initial: ContentRegistry; // the first publication — normally the engine's own registry
}): ContentArchive;

interface ContentArchive extends ResolutionArchive, ContentChannel {
/** Refuse a registry without `provenance` (`content_provenance_missing`, §10.1) and any
* authored key the running engine reserves or that contains `~` (`protected_string_key`,
* §12). Validate every campaign against its authored strings united with the running
* engine's catalog (a campaign of an unregistered kind refuses `unknown_kind`), refuse a
* held key whose `epochDigest` differs (`content_version_conflict`), then store
* deep-frozen copies and make this the latest. Returns the epochs newly added; an
* identical republish adds none, still becomes the latest — a rollback — and never
* repoints a held key (`90-decisions.md`, 2026-10-09, S131). */
publish(registry: ContentRegistry): CommandResult<readonly EpochRef[]>;
/** Bring back an epoch a previous process published, from exactly what it persisted.
* Never recomposes. Three outcomes, kept apart (16 §5.1, *Restore*): the stored digest
* does not match `epochDigest(stored.form)`, or the form carries a key reserved when it
* was published (any `core.reason.*` key, or one in `reservedKeys`), or the key is already held with a different digest → `integrity_failure`;
* the running kind's `validateCampaign` rejects the verified form → `unsupported`, and
* nothing is entered; otherwise `restored`. `migrateState` is the campaign's own
* (§10.1), reattached by campaign id because a function is never part of the form.
* Moves neither `latest()` nor `current`. */
restore(stored: StoredEpoch, options?: { migrateState?: Campaign["migrateState"] }): RestoreResult;
/** The persistable form of a held epoch — what `restore` takes back. Undefined for a key
* this archive does not hold. */
stored(campaignId: string, campaignVersion: string): StoredEpoch | undefined;
/** The last publication that succeeded. Not the channel: `current` (below) is per campaign. */
latest(): ContentRegistry;
/** The channel member: the version of `scope.campaignId` in the last successful
* publication that held that campaign, or undefined if none did. */
current(scope: ContentScope): string | undefined;
authoredKeys(): ReadonlySet<LocKey>;
}

interface EpochRef {
campaignId: string;
campaignVersion: string;
}

/** An epoch's identity (C28): exactly what is hashed, and exactly what a host persists. */
interface EpochForm {
/** The composed campaign: its `content` already composed, `includes` kept as
* provenance, and no `migrateState`. */
campaign: Omit<Campaign, "migrateState">;
/** The attachments applied to this campaign, in composition order (§10.4). */
attachments: readonly CampaignAttachment[];
/** Authored strings only — `ContentRegistry.provenance` for this campaign (§10.1). No
* engine-owned message, and no string of a campaign it does not compose. */
strings: Readonly<Record<LocKey, string>>;
}

/** Recorded beside the form at publication; not hashed. */
interface EpochPublication {
kindId: KindId;
kindVersion: string;
/** The explicit keys the running engine reserved when it accepted the form (§12), sorted.
* `restore` tolerates an authored key reserved now only if it is absent here. */
reservedKeys: readonly LocKey[];
}

interface StoredEpoch {
form: EpochForm;
publication: EpochPublication;
digest: string; // epochDigest(form)
}

type RestoreResult =
| { outcome: "restored"; ref: EpochRef; warnings: readonly ValidationWarning[] }
| { outcome: "unsupported"; ref: EpochRef;
rejectedBy: { kindId: KindId; kindVersion: string };
publishedBy: { kindId: KindId; kindVersion: string };
errors: readonly ValidationError[];
warning: ValidationWarning } // epoch_unsupported
| { outcome: "integrity_failure"; ref: EpochRef; errors: readonly ValidationError[] };

/** `canonicalDigest` over the form. Exported so a host recomputes it without recomposing. */
function epochDigest(form: EpochForm): string;
/** `sha256Hex(canonicalize(value))` (§10) — the one digest helper a host uses for identities
* the engine states, such as an extension set's (16 §3.4). */
function canonicalDigest(value: unknown): string;

An epoch is its authored form (C28; 16 §3.4). publish builds each campaign's EpochForm from the registry's provenance, never from the merged strings, so a registry that also holds other campaigns, or an engine whose message wording changed, publishes the same digest for unchanged content. Engine-owned messages come from the running catalog whenever a registry is assembled, including one restore rebuilds: historical authored text is preserved, and historical engine-error wording is not promised.

restore's warnings are historical_string_collision, one per authored key the running engine reserves that the form's reservedKeys did not, with details: { campaignId, campaignVersion, key }. The authored text stays at its key; the engine's text for the same key reaches clients through a response alias (§7). unsupported carries epoch_unsupported with details: { campaignId, campaignVersion, potentialRegression }, potentialRegression true when the epoch's kind is registered and rejectedBy equals publishedBy — the same kindId and the same kindVersion — a rejection by the version that published the epoch is a regression to investigate, not an accepted break. The classification is not persisted: the next process validates again.

createContentArchive refuses an initial registry that fails validation the same way publish does, by throwing — a construction error, like every other one this section names.

Every state-taking operation resolves content by the state. scene, view, availableActions, submitAction, previewAction, deserialize, migrate and adoptContent look up content.resolve(state.campaignId, state.campaignVersion) and hand that registry to the kind as KindContext.registry and its campaign as KindContext.campaign (§3.1). The construction-time registry is the default epoch — the one createGame starts on when no version is asked for — not the only content. An EngineHost.archive that does not resolve every campaign of registry at its registered version is a construction error, the treatment a missing kind gets. With no archive supplied, every behaviour below is unchanged.

createEngine reads registry's campaign map once: the default archive and the version createGame starts on both come from that copy, so a later mutation of the registry changes neither. An archive answer that holds the campaign at a version other than the one asked for counts as unresolved — unknown_campaign wherever a version is resolved, and a construction error for the default epoch (90-decisions.md, 2026-10-09, S129).

submitAction is the whole loop, in the core:

submitAction(state, actionId, params):
1. kind = kinds[state.kindId]; seq = state.actionLog.length // 0-based, monotonic; counts epoch entries too
registry = content.resolve(state.campaignId, state.campaignVersion) // by state, above
campaign = registry.campaigns[state.campaignId]
2. handle = rngHandleFor(state.seed, { kind:"action", seq }) // §8 — derived, not carried
3. emit = resolutionEmitter(emitter, state.gameId, seq) // 05 §4 — ordinal starts at 0
4. result = kind.advance(state.kindState, actionId, params,
{ registry, campaign, rng: handle, derive, seq, emit }) // §3.1 — `derive` closes over the seed
5. if result.error → return { ok:false, errors:[result.error] }, state unchanged // ActionResult.errors is a list (§12)
6. newState = {
...state,
kindState: result.state,
status: result.status,
actionLog: [...state.actionLog, { seq, actionId, params }],
}
7. return { ok:true, value:newState, errors:[], warnings:[],
changes:result.changes, messages:result.messages }

previewAction runs that same path against a null emitter. Its successful value is a prospective state for rendering only: the caller must project it and discard it, never persist it. The input state remains unchanged and preview emits no action lifecycle event, so it cannot masquerade as a committed command.

A rejected action does not advance seq. Step 5 returns without appending, so the next attempt computes the same seq from the same log length. That is deliberate — the log is the replay spine and a refused action is not part of it — but it means two rejected attempts emit events with identical (gameId, seq, ordinal). Observability states that limit rather than papering over it, and disambiguates at the boundary (05-observability.md §5, §6).

Immutability is unconditional (games/04-engine-specification.md §11.3): every operation returns a new envelope. Its converse holds too: the engine owns what it logs. submitAction and previewAction copy params once on entry, and both step 4's advance and step 6's LoggedAction receive that copy, so a caller mutating its own object afterwards cannot rewrite the replay spine. ActionParams is a flat record of primitives, so a shallow copy is the whole value.

deserialize resolves a state against this host, or refuses it. A shape-valid envelope is rejected with unknown_campaign when its campaignId is not in the registry, with unknown_kind when its kindId is not registered, and with invalid_state when both resolve but kindId is not that campaign's own kindId. Last, the kind judges the payload: Kind.validateState(kindState, campaign) (§3) returning false, or throwing, rejects with invalid_state at path: "kindState". Each rejection emits core.deserialize.rejected (05-observability.md §8), and migrate inherits all four. The kind agreement check is the raw path's copy of a cross-check the save path already makes (§10.2). Without it, a state could hand one kind's advance another kind's campaign; without the last, a kindState of the wrong shape reaches advance and fails there, as a throw or a NaN, rather than at the boundary.

campaignVersion resolves through the archive, and a log's epoch entries must agree with it. unknown_campaign also covers a campaignId the archive holds at no epoch matching campaignVersion: a state is played against the content it names or not at all, never silently against whatever this host registered under the id (C22). Three invalid_state refusals guard the log: a formatVersion: 1 state whose log carries an epoch entry, a formatVersion: 2 state whose log carries none (90-decisions.md, 2026-10-09, S130), and a state whose last epoch entry's to is not its campaignVersion (C26). A migration entry's from is not resolved: it names an epoch this host could not run, which is why the migration happened. A formatVersion above 2 refuses as every unknown version does. This closes the gap an earlier revision recorded here as known and retained — a state serialized under one resolution and accepted under another (90-decisions.md, 2026-10-08).

createGame assembles the envelope and delegates the start to the kind:

createGame(config, campaignVersion?):
0. gameId = ids.newGameId() // 06 §5.1 — the IdSource port
1. version = campaignVersion ?? registry.campaigns[config.campaignId].version
campaign = content.resolve(config.campaignId, version)?.campaigns[config.campaignId]
// missing → unknown_campaign; kind = campaign.kindId
2. seed = config.seed ?? ids.newSeed() // 06 §5.1 — recorded in the envelope
3. startHandle = rngHandleFor(seed, { kind:"system", system:"start", seq:0 }) // §8
4. startEmit = resolutionEmitter(emitter, gameId, 0) // 05 §4 — seq 0, ordinal 0
5. init = kind.initialState(campaign, { registry, campaign, rng: startHandle, derive, seq: 0, emit: startEmit })
// a kind that settles at start (story-graph, 03 §8.2) draws its initial
// random transitions from startHandle, and reports "ended" if it settled to one
6. return the envelope { kindId: campaign.kindId, campaignId: campaign.id,
campaignVersion: campaign.version, seed,
status: init.status, kindState: init.state, actionLog: [] }
// init.changes / init.messages ride out on the CommandResult

The start resolution uses seq: 0 for both the RNG stream and the emitter, matching the first action's numbering; the two never collide because the stream is system:"start" rather than action (below), and because the emitter's ordinal restarts per resolution.

The start stream (system:"start") is deliberately distinct from the per-action streams submitAction uses ({ kind:"action", seq }), so a start-of-game random draw can never collide with an action's — the initial settle is reproducible on its own stream.


5. Configuration​

interface NewGameConfig {
campaignId: string;
seed?: string; // omitted → the store generates one and records it
audience?: ProjectionAudience; // default "player"
kindProfileData?: unknown; // §7.1 — the resolved cross-game slice, not the profile
}

The kind is not named here — it is a property of the campaign (Campaign.kindId), resolved from the registry. A client starts a game by campaign; whether that campaign is a story graph or a simulation is invisible to it.

kindProfileData is an input, and that is the whole of why it sits here. §7.1's standing rule is that nothing in resolution reads a profile, and §7's is that profileId never appears on this type or in GameState. Both survive: the session store reads the profile, extracts and migrates the slice for this campaign's kind, and writes the resolved value into the config it hands the pure engine. No identity travels, and no ambient read happens during resolution — by the time advance runs, whatever the kind kept from the slice is ordinary kindState.

Putting it anywhere else breaks the replay oracle. ReplayFixture.config is a NewGameConfig (07 §2), so a value carried here is captured with the fixture and reproduces; a value reaching initialState through KindContext instead would not be, and a captured session that started from a profile would silently stop being reproducible — the oracle would still pass while asserting something weaker than it claims. That is the same objection 90-decisions.md records against feeding a profile into KindContext for projection, applied to initialization, and it points the same way. 08-session-capture.md §2's privacy rule is met by construction rather than by exception: what travels is kind-declared content ids and integers, the same category as ActionParams, and never profileId.

The type is unknown for the same reason GameState.kindState is (§2): the core must not depend on a kind. It is opaque here, opaque on the profile record (§7.1), and typed only inside the kind that declared it.


6. Scenes and Actions (Generic)​

The unified surface every client renders. A kind projects its current situation into this shape; a story graph and a simulation both produce a Scene.

interface Scene {
gameId: string;
status: GameStatus;
body: SceneBody; // kind-rendered
actions: AvailableAction[];
view: PlayerView; // the projection (§9), bundled for convenience
}

interface SceneBody {
textKey: LocKey;
text: string; // rendered, with visible-state params substituted
}

interface AvailableAction {
id: string; // the actionId to submit
labelKey: LocKey;
available: boolean; // requirements met
reasonKey?: LocKey; // present iff not available — Transparent Consequences
}

type ActionParams = Readonly<Record<string, string | number | boolean>>;

AvailableAction describes a verb, not its parameter space. A kind uses it to expose a gated choice list — one entry per thing the player may currently do, available and reasonKey saying whether and why not. That is a pattern a kind may use, not the default with exceptions: it is the right shape exactly when the set of distinct submissions is small enough to enumerate, and the wrong one as soon as an action carries parameters, because enumerating a verb × its parameter domain is combinatorial.

How each of the three kinds actually lands, since the spread is the point:

  • story-graph uses it as a gated choice list — an AvailableAction is a node choice, available/reasonKey come straight from its requirement gate (03 §4), and it declares no params at all. This is the pattern at its cleanest, and it is why the type looks the way it does.
  • simulation returns its four verbs (plan.add/plan.remove/plan.clear, end_week) and pushes the whole parameter domain — which ActionTypes are offerable, and the plan itself, so a client can compute a valid plan.remove index — into SimulationView.plan (10 §9).
  • world-graph does the same for spatial verbs, and is the kind that forced the rule to be stated: the build catalogue, staff roster and price bands are projection (12 §7, §10), because build × every definition × every cell × four rotations is not a list.

The invariant across all three is only this: whatever a client can submit, it can discover — from availableActions, from the projection, or from both. AvailableAction is one of the two places that discovery may live, not the place it must.


7. The Session Store and the Platform API​

The pure engine is stateless. The session store is the thin stateful layer clients actually call. It maps the architecture's §10 API onto the pure engine, keyed by sessionId.

The surface splits cleanly into queries (read-only, no persisted state change) and commands (advance or persist). This is a documentation convention for clarity — not CQRS the pattern: there is one state model, no separate read store, no event bus. Just a useful line between "look" and "change."

interface SessionStore {
// ── Queries (read-only) ──────────────────────────────
listCampaigns(profileId?: string): Promise<CampaignCatalog>; // session-free — §7.3
getScene(sessionId: string): Promise<Scene>;
getView(sessionId: string): Promise<PlayerView>;
getStrings(sessionId: string): Promise<StringTable>; // resolve LocKeys — below
listSaves(profileId: string): Promise<readonly SaveSummary[]>; // player-keyed, totally ordered — §7.4
previewAction(sessionId: string, actionId: string, params?: ActionParams): Promise<SessionActionResult>; // resolves prospectively, then discards

// ── Commands (advance or persist) ────────────────────
createSession(config: CreateSessionConfig): Promise<SessionHandle>; // profileId lives here
resumeSession(sessionId: string): Promise<Scene>;
submitAction(sessionId: string, actionId: string, params?: ActionParams): Promise<SessionActionResult>;
saveGame(sessionId: string): Promise<SaveHandle>; // named/manual save
loadGame(saveId: string): Promise<SessionHandle>;
deleteSave(profileId: string, saveId: string, expectedSavedAt: string): Promise<void>; // §7.4
branchSession(sessionId: string, atActionCount: number): Promise<SessionHandle>; // §7.4
}

interface SessionHandle { sessionId: string; scene: Scene; }
interface SaveHandle { saveId: string; savedAt: string; savedAtSeq: number; }

/** §7.4. Save-list metadata only — never a blob, never an envelope, never kind content. */
interface SaveSummary {
saveId: string;
campaignId: string;
savedAt: string; // Clock-stamped (06 §5.4); the primary sort key
savedAtSeq: number; // actions logged at the moment the save was taken
}
/** §7.3. `strings` resolves every `LocKey` the summaries carry, and nothing else. */
interface CampaignCatalog {
readonly campaigns: readonly CampaignSummary[];
readonly strings: StringTable;
}

interface CampaignSummary {
campaignId: string;
kindId: KindId;
titleKey: LocKey;
progress?: CampaignProgress; // present iff `listCampaigns` was given a `profileId` — §7.3
}

interface CampaignProgress {
discovered: number; // distinct `terminalId`s this profile has reached here
total: number; // what `Kind.terminalCount` reports for this campaign
}

interface CreateSessionConfig extends NewGameConfig {
profileId?: string; // omitted → anonymous session; see §7.1
}

/** What a client gets back from an action. Never the envelope. */
interface SessionActionResult {
ok: boolean;
scene?: Scene; // the new scene, on success — a projection (§9)
errors: ValidationError[];
warnings: ValidationWarning[];
changes: StateChange[]; // audit records; the store returns only `visible: true` (§12)
messages: OutcomeMessage[]; // likewise, only `visible: true`
}

type StringTable = Readonly<Record<LocKey, string>>;

submitAction returns SessionActionResult, not ActionResult. ActionResult extends CommandResult<GameState> (§12) — its success value is the envelope, seed and action log and opaque kindState included. That type is correct for the pure engine (§4), whose caller is the store; handing it to a client would put raw state on the other side of the projection boundary and make §9 a convention rather than a guarantee. The store unwraps it and returns a Scene.

The store gates visible, for every audience. submitAction and previewAction drop every StateChange and OutcomeMessage whose visible is false, on accept and reject alike and for ai as well as player: a gate left to the client hides nothing from the client. The engine's ActionResult stays complete. It is the audit surface the profile fold (§7.1), replay and observability read, and the store folds the profile from it before it projects the result.

createSession takes CreateSessionConfig. It previously took NewGameConfig, which carries no profileId — leaving CreateSessionConfig defined and unreachable, and no way for a client to start the profiled session MVP §5 requires for cross-session achievements. profileId stays off NewGameConfig and out of GameState (§7.1); it is a session input, which is exactly what this type is for.

Why getStrings is a store operation. Every client-facing type carries LocKeys — Scene.actions[].labelKey, CampaignSummary.titleKey, OutcomeMessage.key, ValidationError — and a client that may call nothing but this store (09 §2) otherwise has no way to render any of them. Resolving them inside the DTOs was the alternative and is worse: it would bake a locale into the projection and lose the property that clients never string-match English (§12). The table is keyed by the session's campaign, its live epochs (below, C27) and the locale the session was created with; a locale switch is a new session, which is all the MVP's single locale needs. Its engine-owned entries come from the running engine and its authored ones from the epochs, under the response aliases below.

Dropping a session from memory is a host operation, not a store one. createSessionLayer returns SessionLayer (10-design.md 06 §4), which is this SessionStore plus forget(sessionId): it waits for any command holding the session and any persistence read in flight, then drops the cached record without a write. A read that began before forget and settles after it is returned to its caller but not cached. It never changes serialize(). It is kept off SessionStore so the client surface and the coverage checklist (09 §4) stay one-to-one: no client should be able to call it.

Capturing a session as a fixture is a host operation for the same reason, and a stronger one. SessionLayer.captureSession(sessionId, name) returns the session as a ReplayFixture (10-design.md 07 §2, 08 §2.1). It is read-only and shares branchSession's refusals (§7.4, Capturing a session). The fixture carries the seed, which never reaches a client (§6). If a client could call capture, it could read every future draw in its own session.

The store persists the envelope (§2) and nothing else about play. Wall-clock timestamps, owner ids, and other host metadata live on the store's record, outside the replayable GameState. This is the boundary that keeps determinism intact while still supporting "resume on another device" (architecture §2).

createSession generates and records a seed when the config omits one, so a resumed or replayed session is always reproducible.

Two independent lock domains, and they are part of this contract. The store holds a serialized blob per session and mutates it in place, so "read the blob, resolve, write it back" is a read-modify-write and needs saying who may run concurrently with whom:

  • Per sessionId — every operation that touches one session's blob queues behind its predecessor for that session. Two submissions against the same session therefore resolve in the order they acquire the lock, never interleaved, so neither can read a blob the other is about to overwrite.
  • Per profileId — the profile upsert (§7.1) is its own load-merge-save, and two different sessions may legitimately share one profileId; that is what a profile is for. Session locking alone does not serialize it, so profile upserts queue on a second, independent domain keyed by profileId.
  • Per saveId — a save record is written by saveGame and removed by deleteSave (§7.4), and neither is reachable from the session lock: a save outlives the session that wrote it, and deleteSave is addressed by saveId alone. A third domain keyed by saveId is what makes §7.4's compare-and-delete a read-modify-write rather than a race.

Different sessions interleave freely, which is the property that matters for a host serving many players: the domains are keyed, not global, and the two never couple. Nothing here is visible in serialize() output — locking orders commands, it does not change what any one command computes — so this is a store-layer concurrency contract, not a determinism one.

previewAction takes the session lock but is not a command. It shares the per-session queue, so it cannot evaluate one version of a session while a neighbouring submission persists another. Everything else that makes an operation a command, it deliberately skips: it does not increment the attempt counter, does not write the blob, does not touch profile persistence, and emits no action lifecycle event (§4). That is the query/command split above taken literally — a preview is a read that happens to run the write path, so it must be ordered like a write and recorded like a read.

New content reaches a running session at four adoption points, and nowhere else (C25; 16 §5.3). A host offers content through an optional SessionHost.content: ContentChannel (06 §4):

interface ContentChannel {
/** The campaignVersion a session in this scope should be on, or undefined for no offer.
* Called under the session lock. A throw is read as no offer. */
current(scope: ContentScope): string | undefined;
}

interface ContentScope {
campaignId: string;
sessionId: string;
profileId?: string; // absent for an anonymous session
}
PointWhenPersisted in
submitActionAfter the action commits, before the scene is projectedThe accepted action's write
resumeSessionBefore the scene is returnedIts own write
loadGameAfter the save resolves, before the scene is returnedThe new session's first write
branchSessionAfter the retained prefix replays, before the scene is returnedThe branch's first write

At each, under the session lock, the store asks the channel; if it names a version other than the state's, it calls engine.adoptContent(state, version) and persists and projects whichever state results. A refusal leaves the session pinned and the command completes on the old epoch, so adoption never fails a command — nor does a failed adoption-only write at resumeSession: the store restores the unadopted state, returns its scene, and the next adoption point offers again. Queries never adopt, a rejected action never adopts, and previewAction never consults the channel. createSession asks the channel once for the version to start on and passes it to createGame(config, version); that is creation, not adoption, and logs no content entry. It takes the kind and profile data from the offered epoch; an offer the archive does not hold fails creation unknown_campaign, and an answer that is not a string is no offer. At submitAction, the profile fold and the action's messages use the epoch the action resolved on, not the one adopted after it (90-decisions.md, 2026-10-09, S133). With no channel, no session is ever offered content, and every operation above behaves as it did before content epochs existed.

Every projection resolves through the session's current epoch. getStrings returns the union of the string tables of the session's live epochs — its starting version, or the last migration entry's to when the log carries one, and every content entry's to after it — each key resolved from the most recent of those epochs that defines it. The current epoch wins every key it has; a key it dropped still resolves through the epoch that last had it; a session that never adopted gets exactly its one epoch's table. So the table only grows, and any key a result for this session carried resolves in every later getStrings (C27) — including a submitAction that adopts after commit, whose messages come from the old epoch and whose scene from the new. A client whose cached table lacks a key fetches it again. A live epoch the archive no longer resolves contributes nothing, and the union of the rest is returned (90-decisions.md, 2026-10-09, S133). What the union does not promise is a key's earlier wording: a key both epochs define resolves to the newer text (16-content-epochs.md §5.7).

Engine-owned keys resolve from the running engine, not from an epoch (C28). The epochs in the union contribute only their authored strings; every engine-owned key a response can carry — the core's core.reason.* set and each registered kind's reasonMessages — resolves to the running catalog's text. Where an authored key equals an engine key, which only a restored epoch can hold (§4, historical_string_collision), the two meanings travel under different keys:

  • A response alias. alias(k) is ~k if no authored key in scope (ResolutionArchive.authoredKeys, §4) equals it, else ~n~k for the smallest positive n whose form is free. Engine keys contain no ~ (§12), so distinct engine keys never share an alias, and an authored entry is never renamed, dropped or overwritten to make room. The scope is archive-wide, so the map changes only when restore adds an epoch or a host archive retires one (16 §6). Where the archive offers no authoredKeys, the scope is the construction registry's authored keys: its provenance when every campaign records one, else every key of its strings the running catalog does not own. That fallback cannot see an authored key equal to an engine key, which only a restored epoch holds, and an unprovenanced registry cannot be published (§10.1), so it never hides a collision.
  • Which references take one. In production, an engine key k is aliased only when some epoch in scope authors k; every other engine reference is emitted unchanged. Under aliasEngineKeys: "all" (06 §4), a test context, every engine-owned reference is aliased. The alias is applied where the engine-owned reference is produced — ProjectionContext.engineRef inside availableActions, scene and project (§3.1), and the store's response builder for errors, warnings and messages after the profile fold and logging consumed them unaliased — never by matching text. The ownership table below says which producer owns which key.
  • One map, every surface. Scenes, views, available actions, previews, errors, warnings, messages, getStrings, listCampaigns' strings and an offline bundle use the same map. A table holds the authored strings of the live epochs behind the response, each unaliased engine key with the running text, and each alias with the running text — so for a colliding k, entry k is the authored text and entry alias(k) the engine's.
  • Aliases are presentation, not identity. A client must not persist an alias apart from the table it arrived in. No alias reaches GameState, an action log, the profile fold or serialize() (C29).
ProducerKeyOwner
Core rejections, ValidationErrors and store warningscore.reason.${code}engine
A kind's resolve and validation, from reasonMessages${kindId}.reason.${code}engine
Story-graph scene reasonKey, and its requirement-failure errorrequirementFailKey if the choice sets one, else core.reason.requirement_unmetper value: authored, else engine
World-graph action labels and scene textworld-graph.action.*, world-graph.scene.*engine
Simulation's fixed investment accountsimulation.finance.investment.*engine
World-graph building_broken and scenario_resolved alerts' titleKey / messageKeyworld-graph.alert.*engine
Any LocKey read from content or kindState — titleKey, nameKey, an incident_active alert's titleKey / messageKey, descriptionKey, displayNameKeyas authoredauthored
OutcomeMessage.keyper producer, by the rows aboveper producer

A mixed-origin field marks each value's owner on an internal carrier between the producer and the response builder; it is not exported, not serialized, and never part of a public type.

7.1 The Profile Store​

Achievements must outlive a game (MVP §5, 03 §7), but nothing durable may sit inside GameState. So the profile is a second store beside the session store, at the same layer — stateful, I/O-doing, and invisible to the pure engine.

interface PlayerProfile {
formatVersion: 3;
profileId: string;
achievements: readonly AchievementRecord[];
terminals: readonly TerminalRecord[]; // §7.3 — the cross-session half of campaign progress
kindData: readonly KindProfileRecord[]; // below — the kind-owned cross-game slice
}

interface AchievementRecord {
campaignId: string; // achievement ids are only unique within a campaign
achievementId: string;
}

interface TerminalRecord {
campaignId: string; // a terminalId is only unique within a campaign (§3.2, §17)
terminalId: string; // `KindOutcome.terminalId` — a published id, never a value
}

/** At most one per kind. Ordered by `kindId` ascending, so a profile has one serialization. */
interface KindProfileRecord {
kindId: string; // a `KindId` for every kind this build knows; see below
dataVersion: number; // the `Kind.profileData.version` this `data` was written under
data: unknown; // opaque to the core, exactly as `GameState.kindState` is (§2)
}

type ProfileWarningCode =
| "profile_missing" | "profile_corrupt" | "profile_write_failed"
| "profile_kind_data_unreadable" | "profile_kind_data_rejected";
interface ProfileWarning {
code: ProfileWarningCode;
profileId: string;
kindId?: string; // present iff the warning is about one `KindProfileRecord`
}

interface ProfileLoadResult { profile: PlayerProfile; warnings: readonly ProfileWarning[]; }
interface ProfileSaveResult { ok: boolean; warnings: readonly ProfileWarning[]; }

interface ProfileStore {
load(profileId: string): Promise<ProfileLoadResult>;
save(profile: PlayerProfile): Promise<ProfileSaveResult>;
}

Rules, all of them determinism-preserving:

  • Profile identity is a session concern. profileId lives on CreateSessionConfig and the store's record — never on NewGameConfig, never on GameState. The pure engine has no idea profiles exist. A manual save preserves that association the same way: profileId round-trips through the store's own save record, never through the serialized SaveEnvelope, so loadGame restores the same profile a saveGame'd session had.
  • Nothing in resolution reads a profile. A kind unlocks into its own kindState (03 §7) and emits an achievement_unlocked StateChange (§12). After a successful action, the session store idempotently upserts those records through the ProfileStore. Profile contents and write outcomes never feed back into advance.
  • Anonymous by default. No profileId → no read, no write; achievements persist only for that game. Cross-session persistence is opt-in.
  • Degradation is a warning, never a failure. Missing or corrupt loads return an empty formatVersion: 3 profile plus profile_missing / profile_corrupt. A failed write returns profile_write_failed and does not roll back the completed game action — the game is authoritative, the profile is a mirror.
  • Terminals mirror exactly as achievements do. After an action whose AdvanceResult.status is ended, the session store reads Kind.outcome and idempotently upserts one TerminalRecord for the terminalId it names, on the same write as the achievement upsert. A null terminalId on a terminal outcome records nothing. Nothing in advance or project ever reads one back, so this cannot perturb determinism any more than the achievement mirror can.
  • Kind-owned data mirrors the same way, and reads back as an input. A kind that declares Kind.profileData (§3) gets one KindProfileRecord; the store folds it forward after a successful action, and hands it back through NewGameConfig.kindProfileData (§5) when a new session for that profile starts. Both halves keep the two rules above intact — the write is after resolution, and the read is before it. Kind-owned cross-game data, below, is the whole of it.

formatVersion has moved twice, and both migrations are total, which is why both are stated here rather than seamed as functions. A version-1 profile reads as { ...profile, formatVersion: 3, terminals: [], kindData: [] }; a version-2 profile reads as { ...profile, formatVersion: 3, kindData: [] }. No field is renamed, removed or re-typed at either step, so neither can fail. The ProfileStore implementation owns this migration — it is the one part a host adapter must reproduce, because the adapter is what reads the stored document, and it needs no kind registry to do it. The per-kind dataVersion migration below is the opposite, and is core-owned for the same reason inverted.

A player who finished games under version 1 starts at discovered: 0 for those campaigns and re-earns the count by finishing again. That loss is accepted deliberately: the alternative is reconstructing terminals from stored session blobs, which would mean deserializing every save a profile ever touched to recover a number that exists only to decorate a shelf. The 2 → 3 step loses nothing, because no version-2 profile ever held kind data.

formatVersion: 3 is a literal type, and the break is deliberate. A host constructing a PlayerProfile fails to compile rather than writing a 2 document this build then migrates on every load. This is durable player data; a loud break at the one place that constructs it is cheaper than a silent per-load rewrite nobody notices.

Kind-owned cross-game data​

A kind's own state cannot outlive a game — that is GameState's whole shape (§2). Some of it is meant to: simulation's "profile"-scoped event chains (10-simulation-kind.md §2.2) exist precisely to advance across games a player has already finished. This is where that lives, and the core never learns what it is.

Ownership. KindProfileRecord.data is owned end to end by the kind whose kindId it names. The core stores it, sizes it, versions it and hands it back; it never reads inside it, never merges it, never validates its contents, and never declares a type for it. The only code that interprets a slice is that kind's own Kind.profileData (§3), engine-owned per architecture N2 and 06-extensibility.md §7. No kind's type appears in the core as a result — not simulation's, and not any later kind's.

Writing — the third mirror. After a successful submitAction on a profiled session, and on the same profile-keyed write as the achievement and terminal upserts (§7's profileId lock domain), the store calls kind.profileData.fold(current, campaign, changes) with the record's current data (or undefined) and that action's AdvanceResult.changes. The returned value replaces data, and dataVersion is stamped to kind.profileData.version. Three outcomes, and only the first writes:

  • A different value — written, in kindId order, on the same ProfileStore.save as the other two mirrors.
  • A canonically equal value — no write. This is what makes fold's idempotence observable rather than merely claimed: reapplying a transition the profile already holds produces the same bytes, the store skips the save, and no duplicate entry, event, achievement or reward can follow from it.
  • A throw, or a result the canonical serializer rejects (canonicalStringify, src/engine/src/core/persistence/canonical.ts — non-finite numbers, bigint, an undefined in a value position) — refused. The previous data is retained untouched and one profile_kind_data_rejected warning names the kind. fold is content-adjacent code and is invoked defensively for exactly the reason resolveSaveEnvelope already invokes migrateState defensively.

Size is validated, and the limit is on the slice. The canonical serialization of a folded data must not exceed 65 536 bytes. Over it, the write is refused exactly as a throw is: prior data retained, profile_kind_data_rejected warned. The cap is per record rather than per profile, so one kind cannot starve another; and it is checked on the write rather than the read, so a profile can never become unloadable because a limit tightened. A kind whose slice approaches this has designed an unbounded accumulator and needs a different shape, not a larger number.

Version is validated, and a mismatch degrades. On load, for each record whose kindId is registered:

  • dataVersion === kind.profileData.version — used as-is.
  • dataVersion < … — kind.profileData.migrate runs and its result is used. A missing migrate, a failed one, or one that throws drops the slice for this session and warns profile_kind_data_unreadable.
  • dataVersion > … — an older build reading a newer profile. The slice is not read, and the same warning is raised.

A dropped slice is not a deleted one. The record stays in the profile byte for byte, so the build that can read it still can; the kind simply starts that session with no cross-game history, which is the same degradation a missing profile already gets.

Migration order, and why it is this way round. The profile's own formatVersion migration runs first, then the per-record dataVersion migration. Same reasoning as §10.2's kind-then-campaign order: the envelope's shape is a precondition for addressing the payload inside it. The split of who runs each is the load-bearing part — formatVersion is the adapter's, because the adapter is what reads the stored document and it needs nothing else; dataVersion is the core session store's, because it needs the kind registry, and a host port must never hold one.

Unknown kinds are preserved, never dropped. A KindProfileRecord whose kindId names no kind in this build's registry is carried through load and save unchanged: not migrated, not sized, not handed to any kind, not counted against anything. kindId is typed string rather than KindId for exactly this reason — a closed union would make such a record fail shape validation and condemn the whole profile as profile_corrupt. The case is real and one-directional: a host running a subset of kinds, or an older build, must not erase a player's progress in a kind it happens not to have.

Reading — once, at createSession, and nowhere else. When createSession is given a profileId and the campaign's kind declares profileData, the store loads the profile, resolves the record for that kindId, migrates it, and passes the result as NewGameConfig.kindProfileData (§5) — before the pure engine runs. resumeSession, loadGame and branchSession do not re-seed: the kind already put whatever it kept into kindState at initialState, and re-reading a profile that has moved on since would make a resumed session differ from the one that was saved. An anonymous session reads nothing, so it seeds nothing.

A branch replays the recorded value, never the profile. branchSession runs initialState again (§7.4), so it needs the exact kindProfileData the source was created with. The store keeps an owned copy of it on the record as initialization (§7.2) and passes that copy to the branch. Reading the profile again would seed the branch from data the source never saw. Passing nothing would seed it as an anonymous game. Either way the branch would diverge from its prefix at the first state.

Invariants​

Each is written to become an assertion. The session store maintains P1–P6; P7 and P8 are obligations on a kind that declares profileData, and the store cannot enforce them for it — which is why they are stated here rather than assumed, and why the kind conformance tests are where they are checked.

  • P1. Nothing a profile holds is readable from advance, project, scene, availableActions or outcome. The only route in is initialState's third argument. Enforced by the seam's own shape: no other member is given one.
  • P2. serialize() output for a session created with profileId set and kindProfileData absent is byte-identical to one created anonymously with the same NewGameConfig. Enforced by code, and by the determinism harness (§14).
  • P3. A KindProfileRecord whose kindId is unregistered in this build survives a load-modify-save round trip byte-identically. Enforced by code.
  • P4. No ProfileStore failure, refused fold, dropped slice, or size rejection changes AdvanceResult, GameState, or whether submitAction succeeded. Every one of them surfaces as a ValidationWarning and nothing else. Enforced by code — the same guarantee profile_write_failed has held since W8.
  • P5. PlayerProfile.kindData holds at most one record per kindId, ordered by kindId ascending, so one profile has exactly one canonical serialization. Enforced by code.
  • P6. A profile write happens only when the folded slice's canonical serialization differs from the stored one. Enforced by code — this is what makes P8 observable.
  • P7. fold is pure: no I/O, no ambient clock, no randomness, and the same (current, campaign, changes) always returns the same value. Enforced by src/engine/eslint.config.js's determinism guard for the mechanical half, by instruction for the rest.
  • P8. fold is idempotent: fold(fold(c, k, ch), k, ch) is canonically equal to fold(c, k, ch). Enforced by a kind conformance test, and made observable in the store by P6 — a non-idempotent fold shows up as a second write with no second action.

7.2 Host Persistence — The Record Store Beneath the Session Store​

§7 says the store keeps host metadata "on the store's record" without naming that record as a type. It is one now, because a host supplies it: the session store is core-owned — locking, stamping, save-envelope assembly and profile upsert all live here — and what a host may replace is the narrower job of reading and writing the records underneath.

/** Host-owned. Deliberately outside GameState, and never replayed. */
interface StoredSessionRecord {
sessionId: string;
blob: string; // the canonical serialization (§2), never a live object
audience: ProjectionAudience;
attemptCounter: number; // telemetry stamping only (05 §6) — never compared by an adapter
revision: number; // the committed-write counter, below — 0 when first written
replayCompatible: boolean;
createdAt: string; // Clock (06 §5.4), never Date.now
updatedAt: string;
profileId?: string; // §7.1 — round-trips here, never through SaveEnvelope
initialization?: SessionInitialization; // absent → written before the field existed: unknown
}

/** What `createSession` handed `initialState` beyond `{ campaignId, seed }` (§7.1). */
interface SessionInitialization {
kindProfileData?: unknown; // absent inside a present wrapper → recorded, and there was none
}

interface StoredSaveRecord {
saveId: string;
campaignId: string; // host-side routing only; the authority is the embedded GameState
blob: string; // a serialized SaveEnvelope (§10.2)
savedAt: string; // Clock (06 §5.4), never Date.now — §7.4's sort key and delete precondition
savedAtSeq: number;
audience: ProjectionAudience;
profileId?: string;
initialization?: SessionInitialization; // copied from the session record, and back onto it by loadGame
}

interface SessionRecordStore {
get(sessionId: string): Promise<StoredSessionRecord | undefined>;
put(record: StoredSessionRecord): Promise<void>;
}

interface SaveRecordStore {
get(saveId: string): Promise<StoredSaveRecord | undefined>;
put(record: StoredSaveRecord): Promise<void>;
/** §7.4. Every record this adapter holds for one profile, in any order — the store sorts. */
listByProfile(profileId: string): Promise<readonly StoredSaveRecord[]>;
/** §7.4. Conditional: remove `saveId` only while its stored `savedAt` still matches. */
delete(saveId: string, expectedSavedAt: string): Promise<void>;
}

interface SessionPersistence {
sessions: SessionRecordStore;
saves: SaveRecordStore;
}

revision is what a multi-writer adapter compares; attemptCounter never is. The two count different things. attemptCounter stamps telemetry (05 §6), and submitAction advances it before dispatch, so a rejected submission moves it with no write behind it. revision is 0 when a record is first written by createSession, loadGame or branchSession — an adoption riding on that first write leaves it at 0. It rises by exactly one on every accepted submitAction write, whether or not an adoption rides on it, and on every resumeSession that adopts; a rejection, a preview, and a resumeSession that adopts nothing never move it. The rule is stated here so no adapter has to infer one:

An adapter that detects concurrent writers accepts a put for an existing sessionId only when its stored revision equals the incoming revision - 1. Otherwise it throws a SessionPersistenceConflict-branded error (below). An adapter that compares attemptCounter instead refuses every write after a rejected action, and strands the session.

initialization is the input a branch cannot recover from the blob. createSession writes it on every record: { kindProfileData } holding an owned copy of the resolved slice, or {} when the session was anonymous or its profile held no slice for the kind. Later changes to the object the ProfileStore returned therefore cannot reach it. saveGame copies it onto the save, loadGame copies it back onto the new session, and a branch inherits its source's. An adapter stores it and returns it unchanged. A missing wrapper and an empty one mean different things, and an adapter must keep them apart. {} says the game was created with no profile data. A missing field says the record predates this field, so its creation input is unknown. Reading a missing kindProfileData as "none" would branch a profiled game as an anonymous one. The field sits on the record and not in GameState, because it is host input like profileId. serialize() output, GameState.formatVersion and every fixture are unchanged by it.

Omitted → in-memory, which is the MVP default. Without persistence the store's own maps are the storage. With it, the store caches sessions and consults persistence only on a miss, bounded by the host's sessionCacheLimit when one is set (10-design.md 06 §5.2). It holds no saves at all: loadGame, deleteSave and listSaves read the adapter every time. Either way a host adapter is a durability layer, not a replacement for the store's bookkeeping.

campaignId on the save record is host-side routing, nothing more. A host that lists "your saves for this campaign" needs it without deserializing every envelope. It is a copy of what the embedded GameState already says, and §10.2's cross-checks at load read the embedded value, not this one — so a divergent copy can misroute a listing but can never load the wrong game.

saveId is the lookup key on SaveRecordStore. get(saveId) and put(record) address the same record, so an adapter that stores under any other key silently makes every save unretrievable — the write succeeds, the read misses, and nothing fails loudly. Stated because it is exactly the defect the first adapter shipped with.

Where sessionId and saveId come from. Both are minted by the session layer, and both are seamed: RecordIdSource (06-extensibility.md §5.7) is supplied on SessionHost and, when present, replaces the layer's own crypto.randomUUID() at exactly the three call sites that mint one — createSession, loadGame, and saveGame. It is a port separate from IdSource (06 §5.1) because these two ids are the ones that never enter GameState: they key the records above, which is host metadata by construction, whereas gameId and seed are serialized replay inputs. Omitted, behaviour is byte-identical to the unseamed minting it replaced — which is what makes it additive rather than a change to §7.

Failures are SessionStoreError, not CommandResult. None of SessionStore's methods carry an error channel — SessionHandle, SaveHandle and Scene have nowhere to put one — so these stay exceptions. What they are not is opaque:

type SessionStoreErrorCode =
| "unknown_session" | "unknown_save" | "storage_failure" // this section
| "concurrent_modification" // this section, below
| "unknown_campaign" | "invalid_state" | "unknown_kind" // §12, the kernel's own
| "save_requires_migration" | "migration_failed"; // §12, the save boundary's

class SessionStoreError extends Error {
readonly operation: string;
readonly code: SessionStoreErrorCode;
}

Every member is a registered ReasonCode (§12) with a shipped core.reason.* string, so a client renders code through the string table like any other rejection and never reads message. That is what makes these safe to surface: a demo showing "could not be saved locally" is rendering storage_failure, not string-matching English (09 §3).

An adapter throwing is storage_failure, with exactly one classified exception. The store catches whatever a host implementation raises and re-raises storage_failure, so a Postgres timeout and a localStorage quota error stay indistinguishable to a client — a client can do nothing different about either, and a host's own exception type leaking through the store would put an unbounded vocabulary on the other side of the boundary.

The exception is concurrent_modification, and it exists because §7's two lock domains stop at the process edge. Per-sessionId locking orders operations within one store instance; a host running several instances over one database has sessions that no lock here serializes, and that host is the only party positioned to detect the overwrite. It signals one by branding an exception rather than by raising a type of its own:

const SESSION_PERSISTENCE_CONFLICT = "SessionPersistenceConflict";

interface SessionPersistenceConflict extends Error {
readonly name: typeof SESSION_PERSISTENCE_CONFLICT;
}

The brand is a string on name, deliberately, and not an instanceof check — a host may resolve a duplicated copy of this package, across which class identity does not survive.

Two rules bound the carve-out, and they are what keep the paragraph above intact rather than merely qualified. The vocabulary stays closed: one brand, one code, and every other adapter exception still maps to storage_failure with no path by which a host adds a third outcome. And a classified failure must be one the caller can act on differently — concurrent_modification earns its place because "re-read the session and retry" is a real and different response, which is exactly what a timeout and a quota error do not have. A later code needs both arguments made here; a brand invented downstream is not a contract.

A failed write leaves no trace in the store's cache, whatever the failure. A record a command creates (a session from createSession, loadGame or branchSession, or a save from saveGame) is published to the cache only after its write resolves. A save that persistence refused is never listed or loadable, from this instance or any other. The paragraph below is the same rule for a record that already exists.

No read sees a write that has not committed. An operation that changes an existing session record (an accepted submitAction, or a resumeSession that adopts) builds the next record as a separate value and writes that. It applies the value to the cached record only after the write resolves. While the write is pending, getView, getScene, serialize and the other reads that take no session lock still return the last committed record. So does the source snapshot branchSession takes on the source's lock (§7.4). Restoring on failure is not enough. A read that ran while the write was pending would already have returned a state that persistence then refused, and a branch taken in that window would have copied it.

A rejected write leaves the store at its persistence. This failure is actionable — the shipped core.reason.concurrent_modification string tells a player the session changed elsewhere and to refresh — so a refused value must never reach the cache. With the rule above it never does, and the next read serves the last committed state. storage_failure keeps the cached record, because the cache was right and only the write failed.

On concurrent_modification the store also evicts. The conflict means another writer committed, so the cached record is itself stale, and only a fresh read from persistence lets the retry build on the winner's write. A command already queued on the session lock holds the same record, still at the stale revision, so the adapter refuses its write too.

7.3 The Campaign Catalog​

listCampaigns is the only operation a client calls before a session exists, and it was the one operation written as though it could not fail and could not wait. Two consequences followed, and this section settles both.

listCampaigns(profileId?: string): Promise<CampaignCatalog>;

It is asynchronous because a store is not required to hold its campaigns in memory. The synchronous signature made that a silent requirement: a fetch-backed SessionStore could not satisfy it at all, so the first host to try prefetched every summary at composition time and closed over them. That works and is not what the contract said — it said a store must already be a registry before it is a store. Every other query on this interface is already Promise- returning; this one was the outlier, not the norm.

The result carries the strings, so a catalog is renderable without a session. titleKey stays a LocKey — it is not resolved into the summary — and CampaignCatalog.strings is the table that resolves it. This is getStrings (§7) applied one layer earlier, for the same reason and with the same property: no locale is baked into a returned DTO, and a client never string-matches English.

strings carries exactly the keys the summaries carry, and nothing else. It is not the registry's table. That bound is load-bearing rather than an optimization: the full table holds every node's authored prose, so shipping it to a visitor choosing a campaign would hand out the whole of every story before a session began — a spoiler leak dressed as a convenience, and precisely the reach-past-the-boundary this section exists to close. A catalog that grows a new LocKey grows its table by that key.

The catalog is titles and progress. It is never content. No node, no variable schema, no choice, no achievement definition, no Campaign.content in any form. A client that needs those starts a session.

Order is the registry's own iteration order, which registry assembly fixes when it freezes (§10.1), and this section does not impose a sort on top of it. Two calls against one registry return the same order; a client that wants a different one applies its own.

Progress, and the profileId that gates it​

progress is present on a summary iff listCampaigns was given a profileId, and absent otherwise — an anonymous catalog reports nothing, matching §7.1's anonymous by default. When present:

  • discovered is the number of distinct TerminalRecord.terminalIds the profile holds for that campaignId (§7.1).
  • total is what Kind.terminalCount (§3.2) reports for that campaign. A kind that omits terminalCount yields no progress object at all for its campaigns, even with a profileId supplied — a discovered with no denominator is worse than silence, because it renders as progress toward an unknown target.

Counts only. Never ids. The catalog says three of seven, never which three, and never which seven. Ending ids are authored content, and a list of them is a table of contents for endings the player has not found — the projection boundary (§9) applied to a surface that sits outside any projection. This is the whole of the "hidden ending" protection, and it is structural: there is no field for an id to travel in.

A profile is read here and nowhere near resolution. This is a query on the store, which already owns the ProfileStore; advance, project and initialState are untouched and still cannot see a profile. That line matters more than it looks: a projection that varied by profile would no longer be reproducible from { seed, actionLog }, and the determinism harness (§14) would be asserting something weaker than it claims to. Progress is store-assembled precisely so that it cannot reach the kind.

Degradation matches §7.1's. A missing or corrupt profile yields discovered: 0 with its ProfileWarning raised through the same path, never a failed catalog. A player cannot be stopped from browsing campaigns because a progress number could not be read.

Migrating callers​

This is a breaking change to the package root, and it is meant to be caught by the compiler. listCampaigns(): CampaignSummary[] and listCampaigns(): Promise<CampaignCatalog> share no usable call site: an iteration over the old return value does not type-check against a Promise, and awaiting it yields an object rather than an array. Every existing caller — the text client, the MCP list_campaigns tool, and any host — fails to build until it is updated, which is the intended outcome. A signature that silently accepted the old shape would leave a caller reading undefined at runtime.

The migration is mechanical and is stated so no caller has to derive it:

// before
const campaigns = store.listCampaigns();
render(campaigns);

// after
const { campaigns, strings } = await store.listCampaigns(profileId);
render(campaigns, strings);

In-memory stores are unaffected in behaviour, only in shape: a store that holds its registry resolves the promise immediately and does no I/O. Asynchrony is what the signature permits, not what it requires.

The operation count does not change, and the API coverage checklist stays at ten rows. listCampaigns is still one operation and list_campaigns is still its one MCP tool (09-clients.md §4). Resolving the catalog's strings inside the operation rather than beside it is what keeps that one-to-one mapping intact — the count is checkable by counting, and this change leaves the count alone. (§7.4 adds three operations and moves the count to thirteen; what is stated here — that this change left it alone — is unaffected.)

7.4 Session Lifecycle — Listing, Branching, Deleting​

Three operations a host needs and has had to counterfeit: list a player's saves, branch a session at a point in its log, and delete a save. Both existing hosts maintain a private shadow index of saves because SaveRecordStore could be addressed only by a saveId the host had to have remembered — a per-host reimplementation of bookkeeping the store already owns, which is the same argument 06 §5.2 makes for why SessionStore is core-owned in the first place.

One rule decides all three: a lifecycle operation manipulates records, never state. None of them resolves an action, none reaches a Kind, and none may perturb serialize() output for any session it does not create. That is why they belong on the store rather than the engine, and why none of them appears in the replay input { seed, actionLog }.

Listing​

listSaves(profileId: string): Promise<readonly SaveSummary[]>;

profileId is required here, unlike listCampaigns(profileId?) (§7.3). An anonymous save is reachable only by the saveId its maker kept (§7.1, anonymous by default); there is no anonymous population to enumerate, so an optional parameter would have to invent one — either "every save in the store", which is a cross-player leak, or "none", which is a signature that lies. A caller with no profile does not call this operation.

Records with no profileId are never returned, and neither are another profile's. The filter is on the stored StoredSaveRecord.profileId (§7.2), the same field saveGame writes.

The order is total, deterministic, and player-meaningful: savedAt descending, then saveId ascending. Newest first is what a save list is for; saveId breaks a tie when a host's Clock resolution puts two saves in the same instant. Both keys are on the record, so the store sorts and the adapter does not — listByProfile (§7.2) may return any order at all.

savedAt is new on StoredSaveRecord, and the sort is why. savedAtSeq was the only temporal field available and it could not serve: it counts actions within one session, so two saves from two sessions routinely share a value, and ordering by it is not a total order across a player's saves at all. Without a real stamp the tiebreak falls to a random UUID, which is deterministic and meaningless — an order no host could show a player, so no host could retire its shadow index, so the operation would not do the one job it exists for. StoredSessionRecord already carries createdAt/updatedAt on the same reasoning (§7.2); this closes an asymmetry rather than opening a new axis.

It is Clock-stamped (06 §5.4), never Date.now, and it lives on the record rather than in GameState — the §2 rule, unchanged. It cannot reach a replay because nothing in resolution reads it.

Adapter cost, stated rather than discovered. An adapter that persists the record as an opaque blob widens for free. One with an explicit column mapping — Adventures' Postgres store is the case in hand — needs a migration that backfills savedAt for existing rows. There is no correct value to invent for a save taken before the field existed; backfill them to the epoch so they sort last, which is honest about the fact that their real time was never recorded.

A summary is metadata, never content. No blob, no SaveEnvelope, no GameState, no kindState, and no campaign title. Resolving campaignId to a display name is listCampaigns (§7.3), which already carries the LocKey and the table that resolves it; a second string table here would be the projection boundary crossed twice for one string.

Degradation matches §7.1's. An adapter that throws surfaces storage_failure like any other (§7.2). A profile that does not exist is not an error — it lists nothing.

Deleting​

deleteSave(profileId: string, saveId: string, expectedSavedAt: string): Promise<void>;

A wrong-profile delete is indistinguishable from a missing save. Both raise unknown_save, and this is deliberate rather than lazy: a distinct "not yours" code would confirm that a saveId exists to a caller holding no claim on it, which is an existence oracle over other players' records. One code, no oracle, and a host that wants to log the difference can — it has both values.

expectedSavedAt is a precondition, not a convenience. The delete removes saveId only while its stored savedAt still equals the value the caller observed; otherwise it removes nothing and raises concurrent_modification. The failure it exists for is the one this unit names: a player opens a save list, a second writer replaces a record, and the delete authorized against what the player saw would otherwise erase a record they never looked at. The stamp is returned by both saveGame (SaveHandle.savedAt) and listSaves, so every caller already holds one and no extra round-trip is needed to obtain it.

The compare and the delete are one step, not two. SaveRecordStore.delete takes the expected stamp so an adapter can express it as a conditional statement; the store's own per-saveId lock domain (§7) covers the single-instance case. A host running several instances over one database is again the only party positioned to detect the overwrite, and signals it the way §7.2 already established — by branding SessionPersistenceConflict, which the store maps to concurrent_modification. No new brand and no new classified failure: this reuses the one carve-out §7.2 opened, and the argument that earned it holds here unchanged, because "re-read the list and try again" is exactly the different, actionable response.

A refused delete leaves every record untouched — not merely the addressed one. An implementation that removes the record and then discovers the mismatch has already failed the criterion; the condition is evaluated before anything is removed.

Branching​

branchSession(sessionId: string, atActionCount: number): Promise<SessionHandle>;

atActionCount is the number of logged actions the branch retains, so 0 branches at creation and actionLog.length branches at the present. Valid range is [0, actionLog.length] inclusive; anything outside it raises invalid_fork_point and writes nothing. A count is used rather than a LoggedAction.seq because the two coincide only while a log is gap-free, and a count states the intended meaning — how much of this game — without depending on that. It counts log entries of every type (§2): a content entry is part of how much of this game there is, so a fork may fall either side of an adoption, and one taken before it starts on the old epoch.

There is a working reference implementation, and it is not quite this. Issue #266 records Adventures forking by replaying a stored log to an atSeq and writing a StoredSessionRecord straight through persistence.sessions.put, past the store — which leaves the store's own in-memory session cache unaware of the session it just created, and mints the new id with randomUUID() rather than through a port. Both are why this is a store operation rather than a documented recipe.

Callers converting from it change one thing: atSeq: n becomes atActionCount: n + 1 for a dense log, which every log this engine writes is. Stated because the two signatures accept the same type and differ by one — the failure mode is a fork one action short, which replays cleanly and is wrong.

The branch is replayed, not copied. The store creates a game from the source's { gameId, seed, campaignId } and its recorded initialization.kindProfileData (§7.1, §7.2) at its starting version (§2) and replays the retained prefix through the ordinary path — an action through submitAction, a content entry through adoptContent, which must adopt. Then the branch reaches its own adoption point (§7). Before it mints the branch's session id, the store resolves the starting version and every retained content entry's to through engine.content.resolve, requiring an exact version match, so a withdrawn epoch is unknown_campaign rather than a late invalid_state (90-decisions.md, 2026-10-09, S134). Starting from the source's current campaignVersion instead would replay a prefix against content it was never played on. Truncating the source's blob would leave kindState at the present while the log claimed the fork point — a state no sequence of actions produces, and one that would serialize to something replay could never reach.

The branch retains the source's gameId and mints only a new sessionId. This is the load-bearing decision of the operation.

gameId is an envelope field (§2) and therefore in serialize() output, so minting a new one would make "the branch serializes byte-identically through the fork point" false by construction — the assertion would have to compare a serialization with gameId normalized out, which is precisely the normalization 06 §5.1 records the IdSource port as having removed. Retaining it makes the claim literal, and a literal claim is one a golden file can hold.

What this costs, stated plainly: gameId identifies a lineage, not a playthrough. Two live sessions can share one. Nothing in the core is affected — the engine never parses, compares, or derives from the value (06 §3) — and the two places that could have been are not: the §7.1 profile upsert is idempotent on (campaignId, achievementId) and (campaignId, terminalId), so a branch re-earning an achievement its parent already recorded writes the same row twice and changes nothing; and a replay fixture (07 §2) holds { config, actionLog }, which addresses the lineage's inputs and never a session at all.

The new sessionId comes from RecordIdSource.newSessionId() (06 §5.7), not IdSource. That is the port whose values never enter GameState, which is exactly what a branch needs a fresh one of. The unit's own wording says IdSource; it means this, and the distinction matters because minting from IdSource is what would have produced the new gameId this decision rejects.

The source session and every save it made are untouched — not re-stamped, not re-serialized, not re-locked beyond the read. That read waits its turn on the source's session lock, so it sees the last committed record and never a write still in flight (§7.2). A branch is a new record; it is never a mutation of an old one.

profileId and initialization are inherited from the source record, because a branch is the same player's game started from the same input. An anonymous session branches to an anonymous one.

A source whose initialization is unknown branches only where no profile could have seeded it. A record written before initialization existed carries no wrapper (§7.2). If it has no profileId, or its kind declares no profileData, initialState received no profile data, and the branch replays with {}. Otherwise the input is lost: the store raises invalid_state before it mints an id or writes anything. Seeding from the profile as it is now, or from nothing, would produce a branch that silently differs from its prefix.

A session that is not replay-compatible cannot be branched. A StoredSessionRecord with replayCompatible: false has passed through a migrated load (§10.2), and its log is no longer guaranteed to regenerate its state — which is the entire mechanism a branch depends on. It raises invalid_state and writes nothing. A source whose log carries a migration entry (§2) is refused the same way; the entry implies the flag, and makes the refusal readable from the state where it exists. Failing here is the sticky-forward rule doing its job: the alternative is a branch that silently diverges from the game it claims to continue.

A retained action the kind now refuses is refused the same way. A kind narrows what it accepts without a kindVersion bump when no valid save changes (§3's declaration; 90-decisions, 2026-11-01), so a log written by an older build can still hold an entry carrying an undeclared key, or — for simulation — naming an id the kind never issued (10 §4, unknown_target). Re-running it rejects that entry; the store raises invalid_state and writes nothing. A fork point before the entry retains none of it, and branches.

Reproducing a stored session from its log​

Reconstructing a session's blob from { seed, actionLog } requires IdSource.newGameId pinned to the original gameId. This is stated because it is the one non-obvious step and because it has been rediscovered once already: gameId is an input to the envelope, not a derived value, so a reconstruction run under a fresh IdSource differs from the original in that field and in nothing else — a difference small enough to be mistaken for a normalization problem and large enough to fail a byte comparison. 07 §5 named the port for exactly this, and the runner's counting IdSource is the same mechanism applied to fixtures.

The obligation is checkable in both directions, and both belong in the proof: reconstruction under a pinned newGameId is byte-identical to the stored blob, and reconstruction under any other id is observably different. A test that asserts only the first cannot tell a correct pinning from an engine that ignores gameId altogether.

Capturing a session​

interface SessionLayer extends SessionStore { // 06 §4 — host-only, never handed to a client
forget(sessionId: string): Promise<void>;
captureSession(sessionId: string, name: string): Promise<ReplayFixture>;
}

Capture is a whole-log branch that writes nothing and returns its input. It holds the session on its own queue, as branchSession holds its source. It admits the session through the same gates and replays the whole log from the starting epoch, exactly as a branch at atActionCount = actionLog.length would. Then it returns the input to that replay instead of writing a session from it:

Fixture fieldTaken from
namethe caller's name, unchanged
config{ campaignId, seed }, plus kindProfileData from the record's initialization where it is present (§7.2). Nothing else: no audience, no profileId, no sessionId
campaignVersionthe starting epoch, as branchSession resolves it (16 §5.6)
capturedUnderthe running ENGINE_VERSION
submissionsone per log entry, in order: an action entry becomes { actionId, params } copied verbatim, a content entry becomes an AdoptionSubmission to its to

Every refusal is branchSession's, for the same reason, and none is new. The replay and its gates are one implementation shared by both operations, not two that agree. invalid_fork_point cannot arise, since capture takes no fork point. A fault-triggered capture is still a capture of the log, so the submission that faulted is not in it (08 §5).

name must not identify anyone. The store passes it through and cannot check it. Deriving it from the session id, the profile or the account would undo §3.1 of 08 through the one field no kind judges. That rule is the host's to keep (08 §8).

Authorization is host-owned, and SessionStore stays caller-agnostic​

profileId on these operations is a scoping key, not a credential. The store treats it as an assertion already established and never as one it verifies: it has no notion of a caller, no session token, no tenancy, and no way to distinguish a host passing a profile it authenticated from one passing a profile it read off a query string. Authenticating a caller and proving the profileId is theirs is the host's job, entirely, on both existing hosts and any future one.

This is 06 §2's rule read the only way it can be. Authorization cannot change serialize() output, so it is outside the determinism boundary and therefore outside the engine — and a SessionStore that grew an identity model would be a store that could refuse a replay, which is the one thing it must never be able to do.

The deferred trigger, named so it is not rediscovered as a gap. This decision holds while every host owns its own front door. It reopens when the hosting/NEaaS layer (SubZeroDev.Platform) serves multiple tenants from one store instance, because at that point "the host authenticated this caller" stops being one statement and becomes a claim the store is the only party positioned to scope. That is a contract amendment when it arrives, not a defect now; it is issue #281 in 90-decisions.md §2, Found by the first downstream host.

Error semantics​

Every code is a registered ReasonCode with a shipped core.reason.* string (§12), raised as SessionStoreError (§7.2). One code is new; the rest are the existing vocabulary doing what it already does.

Raised byCodeWhenRetryableCaller does
listSavesstorage_failurethe adapter threwYesretry; the list is not authoritative state
deleteSaveunknown_saveno such saveId, or it belongs to another profileNodrop it from the list; it is not the caller's
deleteSaveconcurrent_modificationstored savedAt ≠ expectedSavedAtYes, after re-readingre-run listSaves and re-confirm before deleting
deleteSavestorage_failurethe adapter threwYesretry; nothing was removed
branchSessionunknown_sessionno such sessionIdNothe session is gone
branchSessioninvalid_fork_pointatActionCount outside [0, actionLog.length]Nonew — correct the count
branchSessioninvalid_statethe source is replayCompatible: false, or its log carries a migration entryNobranching this lineage is impossible, not delayed
branchSessioninvalid_statethe source record predates initialization, has a profileId, and its kind declares profileDataNothe creation input is lost; branching this lineage is impossible
branchSessioninvalid_statean action in the retained prefix is one the kind now refuses — logged before the kind declared its params (§3), it carries a key the kind no longer accepts, or it names an id the kind never issued (10 §4)Nothe log cannot regenerate its state under this build; branch before that entry
branchSessioninvalid_statea content entry in the retained prefix no longer adoptsNothe kind's compatibility rule changed under a recorded adoption — 07 §6's divergence, one layer down
branchSessionunknown_campaignan epoch the retained prefix plays on — its starting version or one it adopted — no longer resolvesNothe content was withdrawn (07 §6's unrunnable, one layer down)
captureSessionunknown_sessionno such sessionIdNothe session is gone; nothing to capture
captureSessioninvalid_statethe session is replayCompatible: false, or its log carries a migration entryNothis session cannot become a fixture
captureSessioninvalid_statethe record predates initialization, has a profileId, and its kind declares profileDataNothe creation input is lost; this session cannot become a fixture
captureSessioninvalid_statea logged action or content entry is one the kind now refusesNothe log cannot regenerate its state under this build; capture under the build that wrote it, or not at all
captureSessionunknown_campaignan epoch the log plays on no longer resolvesNothe content was withdrawn; the fixture would be unrunnable (07 §6)

invalid_fork_point joins SessionStoreErrorCode and needs its core.reason.invalid_fork_point string registered with the rest (§12); registry construction rejects an override of it like any other protected core key.

Invariants​

Each is written to become an assertion. The session store maintains all of them; none is delegated to an adapter, which is the point of drawing the seam at SessionPersistence (06 §5.2).

  • L1. listSaves(p) returns every StoredSaveRecord whose profileId === p, and no other record. Enforced by code.
  • L2. listSaves output is sorted by savedAt descending, then saveId ascending, and the order is total — no two distinct records compare equal. Enforced by code.
  • L3. A SaveSummary carries no field that is not on StoredSaveRecord, and never blob. Enforced by the type; a widening is a contract amendment.
  • D1. deleteSave removes exactly one record on success and exactly zero on any failure. Enforced by code.
  • D2. A deleteSave whose expectedSavedAt does not match the stored value removes nothing and raises concurrent_modification. Enforced by code, and by the persistence conformance suite deterministically reproducing the interleaving.
  • D3. A deleteSave for another profile's saveId is indistinguishable in its result from one for a saveId that does not exist. Enforced by code.
  • B1. For a session S and any n in [0, |S.actionLog|], the branch produced by branchSession(S, n) with no content offer at its adoption point serializes byte-identically to S replayed to n from its starting epoch — gameId included, because it is retained. At n = |S.actionLog| it equals serialize(S) exactly, which is the cheapest form of the assertion and the one a golden file should hold. An offer that adopts appends exactly one content entry to that serialization and changes nothing before it. Enforced by code.
  • B2. branchSession performs no write against the source session's record, and no write of any kind on any failure path. Enforced by code.
  • B3. A branch's sessionId is distinct from every other session's; its gameId equals its source's. Enforced by code.
  • B4. A branch's result depends on its source's recorded initialization and never on the source's profile as it is now. Changing that profile after createSession, or mutating the object the ProfileStore returned, leaves B1 holding. Enforced by code.
  • F1. captureSession writes nothing: no record, no cache entry, no StateChange, on success or failure. Enforced by code.
  • F2. For a session S that captures, the fixture's submissions correspond one-to-one, in order, with S.actionLog, and running it through 07's runner rejects none of them. The capture replay is the replay branchSession(S, |S.actionLog|) performs, so B1 is what makes the fixture reproduce S. Enforced by code and by a capture-then-run round-trip test per kind.
  • F3. A fixture carries no profileId, sessionId, audience, timestamp, or host metadata; config has no key outside { campaignId, seed, kindProfileData }. Enforced by code; a widening is a contract amendment to 08 §3.1.
  • F4. Two captures of the same unchanged session under the same build with the same name return equal fixtures. Enforced by code — capture reads nothing a clock or IdSource supplies.
  • A1. No lifecycle operation reads or writes a Kind, and none appears in { seed, actionLog }. Enforced by instruction and by the determinism harness (§14) — a lifecycle operation that perturbed replay would fail an existing fixture.
  • A2. SessionStore never verifies a profileId. Enforced by instruction: there is no credential parameter for it to verify with, which is the structural half of the guarantee.

The coverage checklist moves to thirteen​

listSaves, branchSession and deleteSave are three store operations, so 09 §4's checklist gains three rows and three MCP tools — list_saves, branch_session, delete_save — and the one-to-one mapping that makes "no AI-specific path" (§13) checkable by counting is preserved at thirteen. 09 §4 states the rule this follows: a client never works around a missing operation, and a row added there without an operation here is the signal that logic has leaked upward. This is the sanctioned direction — the operation first, the row second.

@subzerodev/service-contract is kept in step by a gate, not by discipline. Its generation step fails the build when a SessionStore method has no corresponding row, so these three operations produce three rows there or the build stops. That gate is the reason this section can state a count and be believed — the alternative, a checklist maintained by remembering to maintain it, is the arrangement 09 §4 already calls checkable by counting precisely because nothing is left to memory.


8. Randomness​

Fully specified and built. The core owns the seeded PCG32 generator (src/engine/src/core/determinism/pcg32.ts, verified bit-identical to reference vectors) and hands each resolution a scoped handle derived from (seed, streamId) via deriveStream.

Randomness is derived, never carried. deriveStream(seed, streamId) is a pure function: the same pair always yields the same generator, and different streamIds are independent. So a resolution takes a fresh handle, draws from it, and drops it — there is no generator state to thread through the envelope (§2), and replay needs only { seed, actionLog }.

type StreamId =
| { kind: "action"; seq: number }
| { kind: "system"; system: string; seq: number }
| { kind: "agent"; agentId: string; seq: number }
| { kind: "tick"; tick: number; system: string };

interface RngHandle {
nextInt(minInclusive: number, maxInclusive: number): number;
nextPercent(): number;
pick<T>(items: readonly T[]): T;
weightedPick<T>(items: readonly { item: T; weight: number }[]): T;
}

RngHandle exposes no toState(): nothing reads it back. (Pcg32.toState remains on the primitive, for tests and for reference-vector verification.)

Stream-id encoding is part of the contract. deriveStream hashes a string, so the StreamId → string mapping is normative — change it and every seeded outcome changes. It is exactly:

{ kind:"action", seq } → `action:${seq}`
{ kind:"system", system, seq } → `system:${system}:${seq}`
{ kind:"agent", agentId, seq } → `agent:${agentId}:${seq}`
{ kind:"tick", tick, system } → `tick:${tick}:${system}`

Substreams (games/04-engine-specification.md §3.2) mean adding a draw in one place never renumbers another, and a rival kind's draws never perturb the player's. The MVP uses the action stream for play plus two system streams; the machinery for more is already there.

StreamDerived forWhy it is its own stream
action:${seq}submitAction (§4)The play spine — one stream per resolution
system:start:0createGame's initial settle (§4)A start-of-game draw must not collide with the first action's, which shares seq: 0
system:view:${seq}the read-only calls — scene, availableActions, view (§6, §9)A kind that ever drew randomness while rendering would otherwise share a stream with the next submitAction at the same seq, and rendering twice would then change the game

The view stream is normative even though nothing draws on it today. No shipped kind takes a random draw during projection, and none should — projection is a narrowing of state, not a resolution. But KindContext is one type and a read path must supply an rng handle from somewhere; supplying the action stream's would let an accidental draw during rendering silently perturb the next action, which is exactly the class of bug substreams exist to make impossible. Giving reads their own stream costs one StreamId encoding and removes the failure mode by construction. The consequence is that the encoding above is as normative as the other two: changing it changes nothing observable today, and would change every seeded outcome the day a kind does draw while rendering.

What goes in agent.seq is normative, and it is not the action seq. It is the agent's own draw counter, stored on that agent in kindState and incremented per draw. Keying it to the action would make an agent's randomness depend on how many actions preceded it, which is precisely what a per-agent stream exists to avoid.

The tick variant is for world-level draws in a kind whose turn advances simulated time — guest spawning, incident rolls, weather. Keying them by tick rather than by seq is what makes a batch of ticks produce the same result as the same ticks taken singly; 12-world-graph-kind.md §5 states the property and why it is load-bearing. system here names the drawing system, not a StreamId variant, so two systems drawing on the same tick stay independent.

weightedPick constrains content. The built implementation requires every weight to be a positive integer and throws otherwise. That makes it a load-time content rule, not a runtime surprise — Tier 1 validation enforces it (03 §11).


9. Projection​

Clients receive a projection, never raw state (architecture §7). The core runs the mechanism; the kind supplies the narrowing.

type ProjectionAudience = "player" | "ai";

interface PlayerView {
gameId: string;
status: GameStatus;
kindView: unknown; // kind-narrowed — e.g. StoryGraphView (03 §9)
}

// Engine.view(state, audience):
// kind = kinds[state.kindId]
// return { gameId, status, kindView: kind.project(state.kindState, audience, ctx) }

The core guarantees the envelope's own hidden fields (seed, actionLog, kindState raw) never reach a client except through kind.project, which is responsible for excluding the kind's hidden state (03 §9 lists the story-graph exclusions). The ai audience is the rival/AI view; widening it is a difficulty setting, declared and visible (games/04-engine-specification.md §6.1) — never granted by accident.

Why ai and not agent. "Agent" was doing two incompatible jobs across these specs: an AI player here, and a simulated entity in StreamId ({ kind: "agent"; agentId }, §8) and throughout 12-world-graph-kind.md, where guests and staff are agents. A spatial kind full of autonomous entities made the collision unavoidable, so the audience took the new name and agent now means exactly one thing: an entity the simulation owns. Renamed before any code existed, which is the only cheap time to do it.

9.1 The Copy Boundary — the Kernel Owns It​

project may return values that alias kindState, and every shipped kind does. The core neutralizes that at the boundary rather than asking each kind to avoid it: every core surface that carries a kind.project result to a caller returns a structural clone of it. The rule is the kernel's, applied in one place, and a kind neither implements nor can defeat it.

Two surfaces carry a projection, and the rule binds both:

  • Engine.view(state, audience).
  • Scene.view (§6), built by Engine.scene(state). §6 declares that field to be the §9 projection, so this is the same obligation rather than a parallel one.

Both are declared in src/engine/src/core/kernel/engine.ts.

What a caller may rely on: mutating anything reachable from a returned view — nested records and arrays, to any depth — leaves GameState, every later view() or scene(), serialize() output, and the action log unchanged. What a caller may not rely on is object identity. Two projections of one state are equal and are never the same object, so a client must not use a view as a cache key or compare views by reference.

Immutable primitives are not copied, and need not be. Strings, numbers, booleans, null and undefined are values; a copy of one is indistinguishable from it. The rule governs reachable containers only. readonly in a view type is a compile-time annotation this rule does not depend on and is not enforced by: WorldGraphView marks its arrays readonly and StoryGraphView does not, and both are equally protected, because neither annotation is what protects them.

The one obligation this places on a kind: a project result must be plain, structurally cloneable data — no functions, no class instance whose prototype the view depends on, nothing that cannot survive the boundary. A kind that returns such a value fails at its first view() rather than silently handing out a live reference, which is the failure mode worth having.

Engine.availableActions is outside this rule and needs nothing from it: AvailableAction (§6) is a flat record of primitives and carries no projection.

Why the kernel and not the kind. A kind is the wrong owner for an invariant no kind can be checked against. All three alias state while projecting today, and each is locally reasonable — a projection legitimately reuses the value it is narrowing. Placing the rule on kinds turns it into an instruction every future kind must re-obey with nothing to catch a lapse, and this repository has already recorded that exact shape failing twice (90-decisions.md — the emitted → registered gap, and the end-of-week stub register: "a rule with no gate, checked only when someone compares the two sets by hand"). At the kernel it holds by construction, for kinds that do not exist yet.

The cost is one copy per read, and it is affordable precisely because a projection is narrow by construction — this section and each kind's own projection section exist to keep it so.


10. Content, Saves, Migration​

10.1 Content Registry​

interface ContentRegistry {
readonly campaigns: ReadonlyMap<string, Campaign>;
readonly strings: ReadonlyMap<LocKey, string>; // built form — see the authoring boundary below
readonly resolution?: ResolutionId; // the pack set this was folded from (11 §4, §6)
/** Per campaign id, what that campaign authored — the input to its epoch identity (§4, C28).
* Set by every engine builder; `ContentArchive.publish` refuses a registry without it. */
readonly provenance?: ReadonlyMap<string, CampaignProvenance>;
}

interface CampaignProvenance {
/** The campaign's own strings plus those of every module it composes, transitively — never
* the core catalog or a kind's `reasonMessages`, even where an authored key equals one. */
readonly strings: ReadonlyMap<LocKey, string>;
/** The attachments applied to it, in application order (§10.4). */
readonly attachments: readonly CampaignAttachment[];
}

interface Campaign {
id: string;
kindId: KindId;
version: string;
titleKey: LocKey;
content: unknown; // kind-specific — e.g. StoryGraphCampaign (03 §1)

/** Modules this campaign composes in at registry build (§10.4). Absent means none. In the
* frozen registry the list stays as provenance only; `content` is already composed. */
includes?: readonly CampaignInclude[];

/**
* Migrates a `kindState` forward when *this campaign's own* content ids or shape changed
* between `fromVersion` and this `version` (§10.2) — a renamed node or achievement id.
* Optional, for the same reason `Kind.migrateState` (§3) is: most version bumps rename
* nothing a save references. Runs at the save-load boundary, after any
* `Kind.migrateState`, and at adoption (`Engine.adoptContent`, §4) on the *target* epoch's
* campaign before the kind judges — never during `advance`. The rule for what it may do is
* the same at both (§10.2).
*/
migrateState?(kindState: unknown, fromVersion: string): CommandResult<unknown>;
}

resolution is optional, and the optionality is the contract. It is present only on a registry resolvePacks folded from an ordered pack set (11 §4) — buildContentRegistry knows no packs exist and has none to name. Making it required would mean inventing a digest over content that came from no pack, which under 11 §6 would change campaignVersion for every existing single-campaign registry and therefore the version every existing save records. The engine never reads it either way: it is inert identity, not an input.

provenance is ownership by origin, not by prefix. The merged strings cannot say who wrote a key once an authored key equals an engine one, so the builders record it as they merge: buildContentRegistry, buildValidatedContentRegistry and buildValidatedPackRegistry all populate it. For a pack registry the per-campaign table is the whole fold minus engine messages, since a pack's strings are not attributed to one campaign. It is optional in the type only so that a hand-built registry — a test, a fixture — still type-checks; such a registry can be played but not published (content_provenance_missing, §12), because without it the archive could only hash the merged table, which is the full-table identity C28 rejects.

Two migrateState functions, two axes. Kind.migrateState (§3) is typed CommandResult<KState> because a kind knows its own state type; this one is typed CommandResult<unknown> because a campaign does not — it only remaps ids inside a state whose shape the kind already fixed. That is also why §10.2 runs them in that order.

Content excludes envelope identity. A kind's content (e.g. StoryGraphCampaign, 03 §1) holds only kind-specific data — it does not repeat id, kindId, version, or titleKey, which live on Campaign here. Authored inline strings are lifted into registry.strings at build time (the authoring boundary below), so content carries no per-campaign string table at runtime. Same anti-drift rule as kindState (§15).

The registry is frozen and pre-validated (§11) before the engine sees it. The engine performs no I/O; a loader package builds the registry from files (architecture §1).

The Authoring → Registry Boundary​

"Built from files" is a typed step, not a hand-wave. Authors write player-facing text inline (03 §12); the runtime sees only LocKeys. Two types and one pure function make that a contract:

interface AuthoredText { key: LocKey; text: string; }

interface BuiltCampaign {
campaign: Campaign; // runtime form — LocKeys only
strings: ReadonlyMap<LocKey, string>; // lifted out of the source
}

Each kind declares a source type paired with its runtime type — for the flagship, StoryGraphCampaignSource mirrors StoryGraphCampaign (03 §1) with every player-facing field typed AuthoredText instead of LocKey. A pure builder validates the source, replaces each AuthoredText with its key, and returns BuiltCampaign. Repeated identical key/text pairs deduplicate; the same key with different text is a hard error.

Registry assembly then validates every built campaign (§11), merges the protected core strings (§12) with kind and campaign strings, and freezes both maps.

Parsing and files live outside the engine. YAML/JSON decoding and filesystem access belong to an outer adapter that feeds unknown into source-schema validation. The engine package never reads a file — that is what makes "the engine performs no I/O" checkable. The MVP ships one locale, English; additional locales are post-MVP and need no type change, only more string tables.

10.2 Save Envelope and Migration​

Carried from games/04-engine-specification.md §16. A save wraps the GameState envelope with the metadata needed to load it safely.

interface SaveEnvelope {
saveFormatVersion: number; // shape of THIS envelope
serializationVersion: number; // version of the canonical serializer that wrote `state`
engineVersion: string;
kindId: KindId;
kindVersion: string; // a kind's code can change independently of the engine
campaignId: string;
campaignVersion: string; // the published version this save was made under
replayCompatible: boolean;
checksum: string;
state: GameState;
}

The four version fields exist because the four things they track change independently: the save wrapper's shape, the serializer, the engine, and a kind's code can each move without the others. Compression and host-side metadata (playtime, title, thumbnail) are deliberately absent — compression has no consumer yet, and host metadata belongs on the session-store record (§7), outside the replayable GameState, so it can never perturb byte-identical replay.

Built during W31 (SessionStore.saveGame/loadGame, core/persistence/envelope.ts), closing what had been a specified-but-unbuilt mechanism since W3. Not every field gates a load the same way:

  • saveFormatVersion / serializationVersion mismatch fails loudly (save_requires_migration, §12) — this unit introduces both, so neither has a real prior value to migrate from yet; a future unit earns that logic only once one of them actually moves.
  • engineVersion mismatch never gates a load by itself — recorded for provenance only, per this section's own reasoning that it changes independently of the others.
  • kindVersion / campaignVersion mismatch is the actual migration: Kind.migrateState (§3) runs first for a kind-state shape change, then Campaign.migrateState for a content-id rename — a shape change is a precondition for content remapping to address the right fields. Either axis missing its migration function when a mismatch is present fails loudly the same way; a registered migration that itself fails does too (migration_failed, §12).
  • checksum covers { state, replayCompatible }, not the whole envelope. The scope is narrow by design and the remaining fields are protected differently — by cross-checks that a checksum could not perform anyway: campaign.kindId against the outer kindId, and both outer ids and the outer campaignVersion against the embedded (and therefore checksummed) GameState's own — a differing version is invalid_state, since the load resolves and migrates from it (90-decisions.md, 2026-10-09, S134). state is in scope because it is the thing being protected; replayCompatible is in scope because nothing else guards it, and flipping a migrated save's false back to true in the stored blob would otherwise silently defeat the sticky-forward rule below. 90-decisions.md records why this is the accepted scope rather than a gap.
  • The kind judges the result. After both migrations, Kind.validateState (§3) is asked about the kindState this version would run — a migrated one in its migrated shape. A refusal fails the load with invalid_state when nothing was migrated (the save itself is corrupt) and migration_failed when something was (the migration produced a state its own kind cannot run).
  • A save whose epoch the archive still holds loads without migration. When engine.content resolves the save's (campaignId, campaignVersion) and its kindVersion equals the registered kind's, the save loads on that epoch exactly as it was made — replayCompatible kept — and moves forward at loadGame's adoption point (§7) by a logged adoption instead. The campaign-version mismatch the bullet above migrates is then a mismatch with the default epoch only, and is not one. Migration remains the path for an epoch the archive no longer holds and for every kind-shape change (16 §7).
  • A successful migration sets replayCompatible: false, sticky forward — once a lineage has passed through a migrated load, it never becomes replay-compatible again, even across further saves that need no further migration.
  • A migration that changes campaignVersion over an epoch-bearing log logs it. When the log already carries an epoch entry, the step that restamps the version also appends LoggedMigration { seq: actionLog.length, system: "migration", from, to } — from the state's campaignVersion, to the registry campaign's, appended only after the kind accepts the migrated state (90-decisions.md, 2026-10-09, S134); the state is already formatVersion: 2 and stays so (§2). Without the entry, such a save would migrate into a state C26 refuses — its last to naming the epoch it left (16-content-epochs.md §3.5; red-team F1). A migration over a log with no epoch entry appends nothing and keeps formatVersion: 1, so a host that never adopts writes the saves it wrote before content epochs. A migration that changes only kindVersion restamps no version and appends nothing. A migration entry is never replayed: branchSession refuses a log carrying one (§7.4), capture records none, and §14's runner fails a fixture that carries one.
  • A migration is idempotent against its own output. Migrating a save, saving it, and migrating again — which is what happens whenever a player loads, plays nothing, and saves, or whenever a fixture is regenerated — must reach a canonically identical kindState. The first pass restamps campaignVersion to the campaign's own (resolveSaveEnvelope), so the second pass finds no mismatch, runs no migration and appends no second migration entry; that is why it holds, and stating the property rather than the mechanism is what makes it testable against a migration that mutates in place, accumulates, or appends. replayCompatible stays false through both, which is the sticky-forward rule above doing its work.

What each axis may do to state, stated as a rule rather than per kind. Kind.migrateState may change the shape of kindState — add a field with a default, drop one, retype one. A campaign migration may only re-address published content ids the state references, and drop or default the references that no longer resolve. Neither may invent play: a migration that awarded money, advanced a week, or unlocked an achievement would make the same save load differently depending on which version it passed through, which is the class of defect replayCompatible: false exists to record and not to license. A campaign migration that finds a reference it can neither remap nor drop fails with migration_failed rather than guessing — this is architecture §8's "map old ids forward or fail loudly", said from the implementation side.

A campaign migration accepts every predecessor it can reach, not only those it re-addresses. Adoption runs the target epoch's migration from the session's own version (16-content-epochs.md §5.4), and so does a load whose epoch is no longer held. An extension that keeps its predecessor's migration is therefore called with that predecessor's version, which has nothing to rename: for such a version the migration returns the state with no id moved. It fails only for a version it cannot reach. A migration written for the version before last would otherwise refuse migration_failed on every adoption onto the extension, and every live session would stay pinned on the old epoch (90-decisions.md, 2026-10-15).

Migration functions are engine-or-content-owned, never a host-supplied port: a port may supply anything that cannot change serialize() output (06-extensibility.md §6), and remapping old ids is definitionally a change to it. Proven in core/persistence/envelope.test.ts against a synthetic kind/campaign (plans/38-save-migration-programme.md). The one real campaign migration is the adventure campaigns' 1.0.0 → 2.0.0, proven in campaigns/adventure-builder.test.ts.

saveFormatVersion vs GameState.formatVersion — different things. saveFormatVersion versions this wrapper; GameState.formatVersion (§2) versions the envelope inside it. They are separate because Engine.serialize / deserialize round-trip a bare GameState with no wrapper at all (§4) — the determinism harness (§14) and the golden files compare exactly that string. Without its own stamp, a standalone serialized envelope would carry no version information. Both move independently; a loader reading a SaveEnvelope checks both.

The migration hazard, made concrete (architecture §8). A save records the campaignVersion it ran. Loading it against a different published version runs migration, which must map old ids forward (a story-graph node id that was renamed) or fail loudly — never strand the player on content that no longer exists. A migrated save is replayCompatible: false: its action log can no longer be guaranteed to regenerate its history, because the rules changed.

10.3 Why Not Event Sourcing​

The design carries an action log, deterministic replay, and byte-identical state — the ingredients of event sourcing. It stops deliberately short of adopting it as the persistence model.

Pure event sourcing makes current state a derived projection: state = replay(log), and you persist the log, not the state. That collides head-on with the migration rule above. A migrated save is not replay-compatible — its log can no longer regenerate its state across a rule change — so under pure event sourcing a migrated save would be unloadable. Instead the core persists current state (the envelope) and keeps the log: you get event sourcing's benefits where they pay off — the determinism harness (§14) and bug reproduction replay from { seed, actionLog } within one version — without its cost, which is loads that break the moment the rules move. This hybrid is a choice, not a gap.

10.4 Campaign Composition​

Content crosses a campaign boundary by composition at registry build, never by traversal (90-decisions.md, 2026-10-07, #293/#292). A host campaign includes another campaign as a module; registry assembly merges the module's content into the host's before validation runs, so at runtime there is one campaign, one session and one GameState. Nothing in the envelope, the save format or any host changes.

/** A pin on a campaign's *authored* version — never a resolution id (11 §6). */
interface CampaignRef { id: string; version: string; }

interface CampaignInclude {
/** The prefix the module's ids take in the host (§17, *Composed ids*). An authored id:
* ASCII `[a-z0-9_-]`, no `:`, unique among the host's includes and attachments. */
alias: string;
ref: CampaignRef;
/** Kind-specific — entry, exit and variable mappings (e.g. `StoryGraphIncludeBinding`,
* 03 §1.1). Opaque to the core, like `Campaign.content`. */
binding: unknown;
}

/** An include a content pack adds to a campaign it does not own (11 §3a). */
interface CampaignAttachment {
hostCampaignId: string;
include: CampaignInclude;
/** Kind-specific — where in the host the module is reached from (e.g.
* `StoryGraphAttachment`, 03 §1.1). */
payload: unknown;
}

/** What `Kind.composeContent` (§3) receives per module: the include as declared, and the
* module campaign already composed — its own includes and attachments merged in. */
interface ComposedModule {
include: CampaignInclude;
campaign: Campaign;
}

interface ComposedAttachment {
payload: unknown;
module: ComposedModule;
}

The split. The core owns the envelope; the kind owns the merge.

StepOwnerFails with
The include's ref.id names a campaign in the same registry inputcoreinclude_missing
ref.version equals that campaign's authored versioncoreinclude_version_mismatch
The module's kindId equals the host'scoreinclude_kind_mismatch
No campaign includes itself, directly or transitivelycoreinclude_cycle
alias is well-formed and unique among the host's includes and attachmentscoreinvalid_identifier, include_alias_collision
An attachment's hostCampaignId names a campaign in the registry inputcoreattachment_host_missing
The host's kind implements composeContentcorecompose_unsupported
Bindings, entries, exits and attachment points are meaningful for this kindkindthe kind's own codes (03 §8.3)

The order is fixed, and determinism depends on it. Campaigns compose in registry-input order; a host's includes in declared order, then its attachments in pack order and, within one pack, declaration order. Composition is depth-first: a module is composed — its own includes and its own attachments — before any host that includes it, and each campaign is composed once and reused. A module is therefore always included in its composed form, so a pack attachment on a module reaches every host that includes it. There is no depth limit; the cycle check is the only bound.

The pin is on the authored version. resolvePacks stamps every folded campaign's version with the resolution id (11 §4), so a pin checked against the stamped value could never match. The fold keeps each campaign's authored version beside it (11 §3a), and the pin is checked against that. In the unpacked path (buildValidatedContentRegistry) the two are the same value.

Composition precedes validation. Every Tier 1 and Tier 2 check (§11) runs on the composed campaign — the content play will actually see. A module stays an ordinary campaign: it is also validated, and playable, standalone. The frozen registry holds the composed content; the host's includes stay on it as provenance, and nothing reads them again.

Composed ids are published ids. A module node x included under alias a is a::x in kindState, in saves and in replay fixtures. Renaming an alias is therefore a rename under C18 — a Campaign.migrateState, never an edit — and so is re-pinning a module whose own ids moved.

Why not compose at runtime. A target chosen at runtime needs call/return frames in kindState, which is a state-shape change and a save migration. The module interface fixed here — entry, exits, inputs, outputs — is exactly what a frame would call, so that remains the extension if it is ever needed (90-decisions.md, 2026-10-07).


11. Tiered Validation​

Every campaign is validated before the registry is frozen. The core runs the tiers; the kind supplies the checks via validateCampaign.

interface ValidationResult {
ok: boolean; // false iff any Tier-1 error
errors: ValidationError[]; // Tier 1 — hard fail
warnings: ValidationWarning[]; // Tier 2 — load but flag
}

interface ValidationError {
code: ReasonCode;
messageKey: LocKey;
path?: string; // where in the campaign
details?: Readonly<Record<string, string | number | boolean>>;
}
interface ValidationWarning {
code: ReasonCode;
messageKey: LocKey;
path?: string;
details?: Readonly<Record<string, string | number | boolean>>; // e.g. restore's (§4)
}
  • Tier 1 — load-time, hard fail: referential integrity, schema conformance, declared variables, path validity, duplicate ids, missing string keys. (Story-graph's Tier 1 is 03 §11.)

  • Tier 2 — load-time, warning: unreachable content, unexpected cycles, and no_reachable_choice — a campaign that settles straight to an ending with no choice node reachable from the start. It loads and plays (§3, InitialStateResult.status reports "ended" immediately); the warning tells an author their campaign is non-interactive without forbidding a deliberate vignette or a single-scene test fixture.

  • Tier 3 — simulation-time: unwinnable campaigns, dead-end states — found by running, not reading. Not part of load, and not part of §14 either: the determinism harness compares a build against itself and cannot answer whether an ending is reachable. Tier 3 is an author-facing check run out of band — npm run validate-campaign in the engine package, tooling rather than shipped engine code (architecture §9.2).

    Its one contractual property is what a clean result means. The search is bounded by construction — an explored-state cap, a turn-depth cap, and the same settle-step cap the real engine enforces — so it reports bounded whenever any cap was reached anywhere. A bounded result means "not proven", never "passed." A caller that reads the two as the same thing gets a guarantee the checker never offered: it declines to credit an ending found exactly at a cap, precisely so it never claims to have explored more than it did.

Why tiered: "the engine validates AI-authored content" (architecture §9) is only a safety property once you say what validation is and what is decidable when. AI output is data; all data goes through the same tiers, whatever produced it.

Which string table validation checks against​

Two of this section's requirements look like they need each other's output. Every campaign must be validated before the registry is frozen (above), and §12 requires that a registered reason code with no localized message fails validation — but the final merged string table on ContentRegistry is a product of registry assembly (§10.1), which runs after validation clears. Read literally, neither could go first.

They are not in conflict, because "a LocKey resolves" is scoped per campaign, not against the merged table:

  • A campaign's own LocKeys — titleKey, and everything Kind.validateCampaign reaches inside content — are checked against that campaign's built string table, the BuiltCampaign.strings the authoring builder lifted out of its source (§10.1). That table is complete for the campaign by construction: a key it authored but did not lift cannot exist. Merging adds other campaigns' keys, which this campaign has no business referencing anyway, so the merged table would not make the check stricter — only later.
  • Reason-code messages are checked against the declaring kind's own message table, once per kind rather than once per campaign, and against the core's shipped core.reason.* set for the base codes. A kind therefore fails registry construction for a gap in its own vocabulary before its messages ever reach the merge.

So the ordering is: validate each campaign against its own strings and each used kind against its own messages; only then assemble, merging core, kind and campaign strings and freezing both maps. buildValidatedContentRegistry (src/engine/src/core/validation/tiered.ts) is the sanctioned entry point that runs them in that order, and it threads each used kind's messages into assembly so the frozen table carries them.

For a pack set rather than a single campaign batch, buildValidatedPackRegistry(packs: readonly ContentPack[], kinds: KindRegistry): CommandResult<ContentRegistry> (same file) is the sanctioned entry point (11 §3, W76): it runs resolvePacks (§7 below) to fold the set, then this same validate-and-assemble sequence against the folded campaigns and string table, and reattaches the fold's resolution id — neither stage alone can produce a validated, resolution-stamped registry. It is exported from the package root alongside buildValidatedContentRegistry.

Composition runs first, in both entry points (§10.4). Each entry point composes every campaign that declares an include or receives an attachment, and only then validates — so the ordering above becomes compose, validate, assemble. A composition failure is a Tier 1 error like any other: it is reported in the same errors list, and the registry is never built. A composed host's own string table, for the per-campaign check above, is its built strings united with those of every module it composed, transitively. A module's LocKeys stay unprefixed (03 §1.1), so the host's content references them by their own keys. This is not the silent cross-campaign dependency the note below rules out: the include declares it and pins its version.

This is a clarification of scope, not a weakening. Nothing here permits an unresolved key into a frozen registry. The merged table is a superset of every per-campaign table plus the core's and the kinds' own, so a key that resolves in the narrower table resolves in the wider one; checking early is strictly earlier, not looser. What it does rule out is a campaign silently depending on a different campaign's authored string — which the merged table would have let through, and which is a real drift surface, since pack resolution (11 §6) can change what else is in that table without this campaign changing at all.


12. Reason Codes, State Changes, Messages​

Kind-agnostic base vocabulary; kinds extend it (Kind.reasonCodes). Clients never string-match English (games/04-engine-specification.md §2.3).

type LocKey = string; // key into the string table; stable, additive, never renamed;
// `~` is reserved for response aliases (§7) — see below
type ReasonCode = string; // stable, machine-readable; additive, never renamed

const BASE_REASON_CODES = [
// the original seven — the kind-agnostic play vocabulary
"action_not_available", "unknown_action", "requirement_unmet",
"session_ended", "read_only_field", "check_succeeded", "check_failed",
// the pure engine kernel: createGame / submitAction / deserialize rejections
"unknown_campaign", "unknown_kind", "invalid_state",
// registry assembly (§10.1)
"string_conflict", "protected_string_key", "duplicate_campaign_id",
// the core's own Tier-1 checks (§11, §17)
"invalid_identifier", "invalid_loc_key", "missing_string_key",
"missing_kind_reason_message",
// the profile store (§7.1) — mirrors ProfileWarningCode
"profile_missing", "profile_corrupt", "profile_write_failed",
"profile_kind_data_unreadable", "profile_kind_data_rejected",
// the save-load boundary (§10.2)
"save_requires_migration", "migration_failed",
// host persistence (§7.2)
"unknown_session", "unknown_save", "storage_failure", "concurrent_modification",
// session lifecycle (§7.4)
"invalid_fork_point",
// the audit vocabulary — a `StateChange.reason`, not a rejection (below)
"achievement_unlocked",
// content-pack resolution (11 §7) — `resolvePacks`, `registry/packs.ts`
"pack_kind_mismatch", "duplicate_campaign_id_in_pack", "pack_dependency_missing",
"pack_dependency_version_conflict", "pack_dependency_cycle", "pack_override_unexpected",
// campaign composition (§10.4) — registry build, before validation
"include_missing", "include_version_mismatch", "include_kind_mismatch", "include_cycle",
"include_alias_collision", "attachment_host_missing", "compose_unsupported",
// content epochs (16 §4.3, §5.4) — publish and adoption
"content_version_conflict", "content_kind_changed", "content_not_adoptable",
"content_incompatible",
// durable epochs (§4; 16 §4.3, §5.1, §7) — publish, restore and the host's catalog
"content_provenance_missing", "historical_string_collision", "epoch_unsupported",
"campaign_version_unsupported",
] as const;

The base set grows; it was never fixed at the MVP. The original seven were the vocabulary one turn needs. Everything after them was added by a unit that found a cross-kind failure mode with no code that fitted — the kernel's three rejections, registry assembly's three, the core's own Tier-1 four, the profile store's five, the save boundary's two, host persistence's four, session lifecycle's one, the audit vocabulary's one, content-pack resolution's six, campaign composition's seven, content epochs' four, and durable epochs' four. That is the intended shape: a code is registered when a real caller produces it, not pre-declared from this list. campaign_version_unsupported is the one base code the engine does not itself produce: a host answers it (409) when a request names a version whose restore was unsupported (§4), and it is registered here so its message ships with the core rather than with each host. It is distinct from a host's own campaign_version_unavailable, which means the version is not offered at all. Composition's seven were the one batch this contract named before their caller existed; they were specified with that caller (§10.4), and W120 shipped them in reasons.ts. Because ReasonCode is additive, never renamed (above), growth costs nothing — a client switching on a code it has never seen falls through to the localized message, which the core ships for every base code. Expect this list to keep growing, and keep it in step with src/engine/src/core/kernel/reasons.ts, which is where the shipped set and its messages actually live.

The core ships their strings. Every base code has a default-English message under a reserved core.reason.* namespace (core.reason.unknown_action, …), shipped with the engine. Registry construction (§10.1) merges core strings with kind and campaign strings and rejects any attempt to write a reserved key — a campaign cannot restyle what an engine-level error says, because clients and tooling depend on those meanings being stable. A key is reserved if it starts with core.reason., or is any other key in the core catalog, or is a key in a registered kind's reasonMessages; the rejection is protected_string_key with details.reason: "engine_reserved". A key containing ~ is rejected too, with details.reason: "response_alias_namespace": that character is where response aliases live (§7), and no engine key contains it — a test asserts so. The shape check that already exists, LOC_KEY_SHAPE in src/engine/src/core/validation/tiered.ts, is enforced for titleKey only and excludes ~ there; the reservation above is what covers every other authored key. ContentArchive.publish applies both rules; restore tolerates a key that was not reserved at publication (§4, historical_string_collision), because refusing it would strand a session that was legal when it began. Kinds own the strings for codes they add (Kind.reasonCodes); campaigns own their narrative strings. Validation fails if any registered reason code has no localized message — that is what makes "clients never string-match English" enforceable rather than aspirational.


interface StateChange {
path: string; // audit record, not a write path (games/04-engine-specification.md §10.4)
op: "set" | "increment" | "decrement";
value: string | number | boolean;
previous?: string | number | boolean;
reason: ReasonCode;
visible: boolean;
}

interface OutcomeMessage {
key: LocKey;
params?: Readonly<Record<string, string | number>>;
tone?: "neutral" | "positive" | "negative" | "absurd";
visible: boolean;
}

interface CommandResult<T> { ok: boolean; value?: T; errors: ValidationError[]; warnings: ValidationWarning[]; }
interface ActionResult extends CommandResult<GameState> { changes: StateChange[]; messages: OutcomeMessage[]; }

StateChange is an audit record emitted by typed reducers, never the mutation mechanism — the discipline the simulation kind arrived at (games/04-engine-specification.md §10.4). It feeds history and the transparency requirement; visible gates what a client receives, and the session store applies that gate (§7).

StateChange is not logging. It is a domain record: localized, returned in AdvanceResult, persisted by what the store keeps, and shown to players. Operational logging and tracing is a separate channel that is emitted to a sink, never returned, never localized, and free to be discarded entirely with no behavioural difference — 05-observability.md §1 draws the line and §2 explains why merging the two would break determinism.

Two StateChange shapes are conventions this platform invented rather than derived from the ancestor, both real in code and load-bearing before either was written down here — 03-story-graph-kind.md §7's achievement_unlocked and §5's consequence_applied. Restated exactly as the code emits them, kind-agnostic in structure even though the two examples are both story-graph's:

// An achievement unlock (03 §7). The path reuses the condition-field name (§18) an
// `achieved.<id>` check already reads, so unlocking and querying agree on one name.
{ path: `achieved.${achievementId}`, op: "set", value: true,
reason: "achievement_unlocked", visible: true }

// A variable write from resolving consequences (03 §5). One coalesced change per
// touched variable per batch, not one per typed op — `op` is always "set" regardless of
// which increment/decrement/set operations actually ran, because 03 §5's clamp-after-
// all-effects rule means an intermediate op has no individually meaningful audit value.
// `previous` is the pre-batch value; `visible` mirrors the variable's own declaration.
{ path: `var.${name}`, op: "set", value: <final value>, previous: <pre-batch value>,
reason: "consequence_applied", visible: <declaration's own `visible`> }

Both are conventions, not requirements — a future kind may need a different shape for an analogous concept, provided it documents that shape here the same way. What they fix is the pattern: an audit record's path names the thing that changed using the same string a Condition would read to check it, and reason identifies why using a stable code a kind-agnostic session store (or a client) can switch on without string-matching prose.

A StateChange.reason is a registered ReasonCode like any other, and both of these are registered. StateChange.reason is typed ReasonCode, and visible gates client display — so an audit record can reach a client exactly the way a rejection can, and owes a resolvable message for the same reason. Neither of the two above had one until reconciliation registered them, which is why this is now stated rather than assumed: achievement_unlocked is base vocabulary (core.reason.achievement_unlocked) because the session store's profile upsert (§7.1) switches on it without knowing which kind emitted it, while consequence_applied is kind-owned (story-graph.reason.consequence_applied, src/engine/src/kinds/story-graph/reasons.ts) because only that kind has a consequence. A kind inventing a third audit reason registers it the same way; there is no separate audit namespace exempt from §12's completeness rule.

Kind-owned reason codes carry their own messageKey namespace, distinct from event names. A kind's Kind.reasonCodes need a localized message the same way the base set does (above), and the convention is <kindId>.reason.<code> — no kind. wrapper, unlike 05-observability.md §9's event namespace, kind.<kindId>.<event>. The two are easy to conflate because they differ only by one segment, which is exactly why this needs stating rather than assuming: a reason-code message key and an event name are different vocabularies serving different consumers (one renders to a player, one traces a resolution), and neither is a namespace for the other. story-graph.reason.unknown_condition_field (src/engine/src/kinds/story-graph/reasons.ts) is the shipping example — a code this kind adds for a condition-evaluation failure with no analogue in the base set, so it has no home but a kind-owned namespace.


13. The MCP Surface​

The tool table itself — args, returns, one tool per session-store operation — moved to SubZeroDev.ServiceContract's mcp-tool-contract.md: it's a hosting-facing contract, not core engine material, even though McpTools (implemented in src/engine/src/mcp/server.ts; see Engine Package) wraps this repo's own session store (§7) with no runtime dependency, tested end to end against it (TODO.md W17). Architecture §10 still holds: no game logic in the adapter, no AI-specific path.


14. Determinism Harness​

The acceptance test with teeth (MVP §5, games/04-engine-specification.md §18.4): a { config, actionLog } fixture replays to a byte-identical serialize().

interface PlaythroughFixture {
name: string;
config: NewGameConfig; // includes a fixed seed
actionLog: LoggedEntry[]; // §2 — content entries replay too (C21)
}

// runner: createGame(config, starting version) → for each entry: an action → submitAction,
// a content entry → adoptContent(state, entry.to), which must adopt → serialize

The starting version is derived from the log exactly as §2 states, and a fixture whose log crosses an epoch needs an engine whose archive resolves every epoch it names. A content entry that refuses on replay fails the fixture, the same as an action that rejects, and so does a content entry whose from is not the replay's current campaignVersion, checked before adopting (90-decisions.md, 2026-10-09, S130). A migration entry fails it outright: it is not a step replay can perform (§10.2).

  • Golden files — committed fixtures with expected serialize() output; a one-byte diff catches an unintended behaviour change across the whole engine.
  • Property tests — N random seeds, each run twice, outputs compared; catches non-determinism on paths no fixture touches.
  • Sink independence — every fixture replays twice, once with nullEmitter and once with createRecordingEmitter(), and both serialize() outputs must be byte-identical. This is what makes observability (05-observability.md §2) safe to have inside a deterministic core: it catches a kind that branches on emission, which no state-only golden file would notice.
  • Stream reproducibility — the same fixture under createRecordingEmitter() twice yields the identical event sequence, so the event stream is itself a golden-fileable artifact (05 §5).

Canonical serialization (§10, built) and seeded RNG (§8, built and reference-verified) are the two properties that make byte-identical achievable at all.

This harness compares a build against itself. It cannot answer did this change alter a game that already exists — a change that alters every game identically is perfectly deterministic and runs green here. That question needs a different comparison, against a previous build, and a projection that survives an intended serialization change. 07-replay.md specifies it.


15. How the Story-Graph Kind Plugs In​

Concrete mapping — and the reconciliation this document forces on 03-story-graph-kind.md.

Core conceptStory-graph realization
GameState.kindStateStoryGraphKindState — current node, variables, turn, visit counts, unlocked achievements, ending id
Kind.advance(actionId)submitChoice → settle (03 §8.2); actionId is the choice id
AvailableActiona node choice, gated by showWhen / requirements (03 §4)
SceneBodythe node's textKey, interpolated (03 §3.1)
Kind.projectStoryGraphView (03 §9) — turn, visible stats, unlocked achievements, ending; hides non-visible variables and visit counts. Scene text and choices are the generic Scene, not repeated here
Kind.validateCampaign03 §11
Kind.validateState03 §8.1 — every field typed, currentNodeId an existing node, every declared variable present with its type
Kind.outcome03 §8.5 — terminalId is the endingId; terminal is "settled onto an EndingNode" (§3.2)
Kind.terminalCount03 §8.5 — distinct endingIds across the campaign's EndingNodes; the denominator in CampaignProgress (§7.3)
RngHandle.weightedPickrandom-transition node resolution (03 §3)
Kind.composeContent03 §1.1 — prefixed module content, a synthesized entry and exits, appended attachment choices. The only kind that implements it

Reconciliation (done in 03). Writing this seam exposed that 03's state duplicated envelope-owned fields — version, campaignId, campaignVersion, seed, status, and the choice log. Those belong to the GameState envelope (§2), not the kind. 03 §8.1 now defines StoryGraphKindState as the kind-specific subset only:

interface StoryGraphKindState {
currentNodeId: string;
variables: Record<string, VarValue>;
turn: number; // kind-maintained (settle advances it)
visitedCounts: Record<string, number>;
unlockedAchievements: string[];
endingId?: string;
}

The choice log becomes the envelope's generic actionLog; turn stays on the kind because a "turn" is kind-specific (a node transition here, a week in the simulation kind).


16. What This Unblocks​

With the seam typed and every MVP-blocking gap decided (OPEN-QUESTIONS.md §1), the build runs against real contracts:

  1. The pure Engine (§4) — createGame, submitAction, scene, view, serialize.
  2. The SessionStore (§7) and the ProfileStore beside it (§7.1).
  3. The registry and its authoring builder (§10.1).
  4. The story-graph Kind implementation (§3, §15) against 03-story-graph-kind.md.
  5. The determinism harness (§14) — now that fixtures have a type.
  6. The MCP server (§13) and text client — thin adapters over SessionStore.

Nothing above is speculative: every type here is exercised by the MVP (MVP.md). TODO.md sequences it as units of work W0–W19.


17. Identifier Conventions​

One fixed shape for every id, so validation, tooling, debugging, and authoring can rely on it. A peer-review recommendation, adopted before content scales.

Kind of idShapeExample
Campaignkebab-casebulgaria-bureaucracy
Nodesnake_casegovernment_office
Choicesnake_case, unique within its nodebegin_again
Variablesnake_caseoffice_visits
Achievementsnake_caseit_builds_character
Endingsnake_caseit_builds_character
LocKey (localization)dotted, type.id[.field]event.pipe_disaster.title, choice.wait, stat.money
Reason codesnake_case verb/staterequirement_unmet

Rules: ids are stable once published (a rename is a migration, §10.2); ids are ASCII [a-z0-9_-] only; LocKeys namespace by content type so string tables stay navigable. Tier-1 validation (§11) enforces the character set and uniqueness.

Composed ids (§10.4) are the one exception to the character set. When a host includes a module under an alias, composition rewrites the module's kind-scoped ids — for story-graph its node, variable and achievement ids (03 §1.1) — to <alias>::<id>. Nested includes chain: a module that itself includes b contributes a::b::x to its host. : appears in no other id, and no authored id may contain it: an authored id, alias or LocKey with a : fails invalid_identifier (Tier 1), so a composed id can never collide with an authored one. Each segment of a composed id still has its category's shape. Campaign ids, LocKeys and reason codes are never composed.

18. Frozen Primitives​

Two shared primitives are held deliberately small, because these are the surfaces that grow without bound if left open (a peer-review caution taken up-front).

The Condition operator set is closed. The comparison operators (equals, not_equals, less_than, less_or_equal, greater_than, greater_or_equal, in, not_in, contains, has_tag, has_flag) plus the tree combinators (all/any/not) and quantifiers (exists/count) are the whole surface — shared with the simulation kind (games/04-engine-specification.md §13.1). Tempting additions — between, matches, arithmetic, inventory() / relationship() / distance() helpers, nested expressions — are out unless a concrete campaign need justifies each one individually. Every operator is permanent maintenance: a new one must be validated, evaluated, projected, migrated, and taught to every tool. The bar to add is high on purpose.

The shape itself, as ported into this repository (core/condition/types.ts):

type ComparisonOperator =
"equals" | "not_equals" | "less_than" | "less_or_equal" |
"greater_than" | "greater_or_equal" | "in" | "not_in" | "contains" |
"has_tag" | "has_flag";

// `value` is optional: `not_equals` against an absent field is authored as `value: undefined`,
// and `JSON.stringify` drops an undefined-valued key, so the wire document never carries it.
interface ComparisonCondition { field: string; operator: ComparisonOperator; value?: unknown; }
interface AllCondition { all: Condition[]; }
interface AnyCondition { any: Condition[]; }
interface NotCondition { not: Condition; }
interface ExistsCondition { exists: { collection: string; where: Condition }; }

// count's own comparison is always two numbers (a match total against `value`) — only
// the six ordering/equality operators, never the array/string-shaped ones, which would
// type-check but always throw at evaluation.
type CountComparisonOperator =
"equals" | "not_equals" | "less_than" | "less_or_equal" | "greater_than" | "greater_or_equal";
interface CountCondition {
count: { collection: string; where: Condition };
operator: CountComparisonOperator;
value: number;
}

type Condition =
ComparisonCondition | AllCondition | AnyCondition | NotCondition |
ExistsCondition | CountCondition;

// What a caller supplies to the evaluator, which knows nothing itself about `var.*`,
// story nodes, or any other kind's field vocabulary.
interface ConditionResolver {
field(path: string): unknown;
collection(name: string): readonly ConditionResolver[];
}

One field of the ancestor's shape did not port. games/04-engine-specification.md §13.1's Condition also carries a CollectionSelector — a closed union of simulation-kind paths (player.inventory, world.npcs, …). None of those are kind-agnostic, so collection here is a plain string, and which strings are legal is entirely up to whichever kind resolves them (kinds/story-graph/conditions.ts for the one kind that exists today). The ancestor citation above stays as provenance — per CLAUDE.md, every games/… citation is provenance, not a second authority — but this section, not that document, is now where the shape itself lives.

Reason codes are additive, never renamed (§12) — saves and replay logs reference them, so a rename breaks old data.

19. Published Narrative Authoring​

The package root is the runtime contract. @the-running-dev/game-engine/authoring is the separate author-time contract for repositories that own campaign source. It exports the generic campaign builder, the story-graph source builder, the simulation source builder with its content-definition source types, the shared adventure builder with its source factory and its migration helper, portable serialization and manifest digests, and replay-runner types/functions. It is deliberately a subpath: a runtime host must not import authored campaign source merely to play published portable JSON.

A kind's export split follows one rule regardless of which kind it is: the builder and its source types are author-time and belong on /authoring; the campaign, state, view and outcome types are what a runtime host compiles against and belong at the root (design/30-slices.md, W88). buildWorldGraphCampaign's root placement predates the subpath and is an exception noted here rather than moved.

SubZeroDev.Adventures.Content owns the canonical source and publication of narrative campaigns. GameEngine owns kinds, validation, portable hydration and authoring primitives. GameEngine may retain a frozen campaign solely as a regression fixture; such a fixture is not published and not listed in a manifest. Frozen fixtures left the package root in the breaking 0.9.0 release (design/30-slices.md, W74c), which has shipped — src/engine/package.json reads 0.10.0. That peg moved once before it landed: this section originally named 0.8.0 as the breaking release, and 0.8.0 was then spent on an additive one. A version reserved by this section is a name to check against src/engine/package.json before a bump, not after.

A third category is sanctioned and is neither of the two above: a kind's own reference campaign — engine-owned content that exists to make the kind registrable and exercisable, never authored by Content and never a frozen fixture — is a package-root export. The instance is buildWorldGraphMvpCampaign / WORLD_GRAPH_MVP_CAMPAIGN_ID, the only content a host can register the world-graph kind against. The line that keeps this from swallowing the rule: the root publishes no narrative campaign, which is the claim src/engine/src/authoring.test.ts enforces in both directions. A second kind adding a reference campaign to the root follows this sentence; a second reference campaign for one kind does not, and wants its own decision.

Portable campaign documents remain format version 2. toPortable is public only through /authoring; fromPortable remains a runtime-root export. Both digest helpers are exported from both surfaces: the root, for hosts verifying fetched content, and /authoring, for authoring pipelines digesting campaign source before publication. digestPortableCampaign digests one campaign file. digestManifestResolution(entries: readonly PortableManifestEntry[]) digests the ordered list of { id, version, digest } taken from the manifest's entries, so it identifies a content set whose files a host has each verified against their entry digest. File paths are excluded as transport details. The recipe includes digest from 0.12.0; until then it was the ordered { id, version } list over PackRef[], so a manifest's resolution changes for unchanged content (OPEN-QUESTIONS.md, 2026-10-27).

PortableCampaignBody's simulation arm gains an optional migration, and the format version does not move. Until now migration existed only on the story-graph arm, which src/engine/src/portable/format.ts documented as a structural fact rather than an omission — correct while migrateFromContent was the only reattachable migration there was. It is not a structural fact about the format; it was one about how many kinds had a declarative migration written, and the simulation kind now has one (10-simulation-kind.md §16). toPortable's throw narrows accordingly: it still refuses a migration for world-graph, which has none.

The version stays at 2 because the change is additive and its silent-loss case cannot happen. A 2 reader handed a document carrying a simulation migration ignores the member and attaches no migrateState — which matters only when campaignVersion actually moved, and that is precisely when resolveSaveEnvelope rejects the load with save_requires_migration (§10.2). The old reader fails loudly at the exact moment the dropped field would have mattered, so bumping to 3 would buy a louder failure for documents that load correctly and nothing for the ones that do not. formatVersion is a literal type, so a bump also breaks every reader including the ones that would have been fine.


20. The Ordered System Pipeline​

Two kinds resolve a turn by running an ordered list of systems: simulation's end-of-week pass (10-simulation-kind.md §3) and world-graph's tick (12-world-graph-kind.md §4.1). Those orders are normative and are owned there, not here. This section owns only the substrate they run on: what applying an ordered list means, and what doing so must never do.

The substrate is engine-internal. It is exported from neither the package root nor /authoring, and no host supplies, replaces, wraps, or observes one — a pipeline sits inside the determinism boundary, which by 06-extensibility.md §2 is the same line as the trust boundary.

It is a fold over an explicit list, and nothing more.

  • Order is the caller's, verbatim. The substrate applies entries in exactly the order given. It never sorts, filters, deduplicates, reorders, or skips, and it holds no registry of systems and no opinion about which belong. A caller that supplies a list is the sole authority on its contents — which is what lets the two normative orders stay owned by their kinds while the mechanism is shared.
  • Every entry runs, every time. There is no short-circuit and no early exit. A terminal or failed result is a value carried in the frame, never a control-flow signal; §4.1's rule that "a terminal result does not interrupt the tick" is a consequence of this, not an exception to it. Where a turn stops early it stops in the caller's own loop around the substrate, never inside it.
  • Each entry is a total function from frame to frame, and the substrate threads each returned frame to the next entry. No entry observes a frame from anything but its immediate predecessor, and the substrate never inspects, merges, or reconstructs one. The frame type belongs to the caller; the substrate is generic over it and reads no field of it.
  • The substrate emits nothing. It holds no Emitter, draws no randomness, and reads no clock. Every event a turn produces is emitted by a system — and per-system trace events are no exception. Where a kind wants one, the entry's own run closes over the system and the emission together, at the point the list is built. simulation does this for kind.simulation.system.ran, which its §11 keeps because a stream naming each system in order localizes an ordering regression to the phase that moved; world-graph declares no such event and wraps nothing. Both are the same substrate, with no flag distinguishing them.
  • It never catches. A system that throws propagates to the caller, with no partial commit and no substitute frame. A throw is an engine defect rather than a game outcome — both existing pipelines already rely on this, in world-graph's per-entry processingTick guard and its finalize-once assertion, and in the dangling-id lookups both kinds share — and catching one would convert a wrong state into a state that still serializes. No reason code covers it, and introducing one would be the mistake.

Why the substrate is emission-free rather than emitting per system. The two callers disagree today, and both are right: simulation's runner emits one trace event per system, world-graph's loop emits none and lets each system speak for itself. A substrate that always emitted would add twenty events to every world-graph tick; one that never emitted would delete the system.ran stream its §11 relies on. Moving the emission into the list entry settles it without a flag — the substrate stays a pure fold, each kind's event stream stays byte-identical to what it produces now, and which events a turn emits remains a question about systems rather than about the machinery that runs them.

The substrate is declared in src/engine/src/core/pipeline/systems.ts: SystemEntry<Frame>, an id the substrate never reads plus a frame-to-frame run, and runSystems, the fold itself.

What this section does not settle. Whether a given kind's systems are shaped to run on this substrate is that kind's own internal matter, constrained by the rules above and by nothing else here. Both shipped tick-driven kinds now are, by different routes. world-graph already had the shape — one (frame) => frame type and a declared id list — and keeps its per-entry processingTick guard by wrapping the list handed to it, since the substrate may read no field of the frame. simulation had none of it — fifteen bespoke signatures, no frame type, and a missedCents handoff from housing into finance_reconcile — and gained an EndOfWeekFrame that carries that handoff explicitly, with each entry pre-wrapped to emit its own kind.simulation.system.ran. Both reshapes were constrained to leave resolution behaviour, ordering, and the event stream unchanged, which src/engine/src/campaigns/pipeline-equivalence.test.ts holds against the committed replay corpus.