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 — see §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: LoggedAction[]; // ordered player actions — the replay spine (§9)
}

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

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

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). */
availableActions(state: KState, ctx: KindContext): AvailableAction[];

/** Render the current situation into a generic scene body (§6). */
scene(state: KState, ctx: KindContext): 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. */
project(state: KState, audience: ProjectionAudience, ctx: KindContext): 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;

/**
* 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>;

/**
* 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;
}

/**
* 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[];
}

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.

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).

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
simulationresolutiongoals_met / failed / week_limit_reached (10 §12)resolution, goalsMet, goalsFailed
world-graphresolutionobjectives_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;
createGame(config: NewGameConfig): 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

/** 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;
}

submitAction is the whole loop, in the core:

submitAction(state, actionId, params):
1. kind = kinds[state.kindId]; seq = state.actionLog.length // 0-based, monotonic
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.

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

createGame(config):
0. gameId = ids.newGameId() // 06 §5.1 — the IdSource port
1. campaign = registry.campaigns[config.campaignId] // 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, `visible`-gated (§12)
messages: OutcomeMessage[];
}

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.

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 campaign and locale the session was created with; a locale switch is a new session, which is all the MVP's single locale needs.

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.

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.

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;
replayCompatible: boolean;
createdAt: string; // Clock (06 §5.4), never Date.now
updatedAt: string;
profileId?: string; // §7.1 — round-trips here, never through SaveEnvelope
}

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;
}

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;
}

Omitted → in-memory, which is the MVP default. The store keeps its own maps either way and consults persistence only on a miss, so 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 differentlyconcurrent_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 rejected write must not leave the store ahead of its persistence. This failure is actionable — the shipped core.reason.concurrent_modification string tells a player the session changed elsewhere and to refresh — so the store's in-memory record must not retain a mutation that persistence refused. An operation that mutates a cached record before persisting it has to restore or evict that record when the write throws. Otherwise the next read is served from the cache, returns the un-persisted state, and the retry the message asks for cannot succeed. storage_failure tolerated this divergence because its own message promises only that the game is still playable; concurrent_modification does not.

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.

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, campaignVersion } and submits the retained prefix through the ordinary path. 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. A branch is a new record; it is never a mutation of an old one.

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

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. 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.

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.

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 savedAtexpectedSavedAtYes, 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: falseNobranching this lineage is impossible, not delayed
branchSessionunknown_campaignthe source's campaignVersion is no longer registeredNothe content was withdrawn (07 §6's unrunnable, one layer down)

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) serializes byte-identically to S replayed to ngameId 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. 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.
  • 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)
}

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

/**
* 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 only, after any
* `Kind.migrateState`, never during `advance`.
*/
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.

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 against the embedded (and therefore checksummed) GameState's own. 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.
  • 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 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 and runs no migration at all; 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.

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, not a real campaign republish — every shipped campaign is still at 1.0.0, so there is nothing real to migrate from yet either (plans/38-save-migration-programme.md).

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.


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>>;
}
interface ValidationWarning { code: ReasonCode; messageKey: LocKey; path?: string; }
  • 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 bandnpm 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 LocKeystitleKey, 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.

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
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",
] 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, and content-pack resolution's six. That is the intended shape: a code is registered when a real caller produces it, not pre-declared from this list. 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 into core.reason.* — a campaign cannot restyle what an engine-level error says, because clients and tooling depend on those meanings being stable. 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 may show.

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: LoggedAction[];
}

// runner: createGame(config) → for each logged action, submitAction → serialize final state
  • 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.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)

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.

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 and digestManifestResolution are public only through /authoring; fromPortable remains a runtime-root export. digestPortableCampaign is exported from both surfaces: the root, for hosts verifying fetched content, and /authoring, for authoring pipelines digesting campaign source before publication.

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.