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
GameStateenvelope, 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-architecturemade the decisions; this turns each into a type. Named ≠ defined ≠ buildable — that lesson, fromgames/, 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.mdis, 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
simulationkind'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 way03-story-graph-kind.mdis, with every typeSimulationKindStatenames 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,
demandBandthresholds, and the housing-quality formula, indexed inOPEN-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.
| Module | Owns | Section |
|---|---|---|
kernel | the GameState envelope, the Engine, submitAction | §2, §4 |
session | the session store, save/load handles, the profile store | §7, §7.1 |
persistence | canonical serialize/deserialize, SaveEnvelope, migration | §10 |
projection | the project mechanism, audiences | §9 |
validation | the tiered validator, ValidationResult | §11 |
registry | the content registry, campaign resolution | §10.1 |
localization | LocKey resolution against string tables | §12, §17 |
determinism | the RNG handle, streams, the harness | §8, §14 |
observability | the Emitter, EngineEvent, sinks | 05-observability.md |
composition | the host roots and the port interfaces | 06-extensibility.md |
Kinds (kinds/) and clients (clients/, mcp/) sit above; the dependency arrow points
only downward — a core module never imports a kind or client.
2. The GameState Envelope
The core owns a kind-agnostic envelope and treats each kind's own state as an
opaque payload inside it. This is the single most important type in the platform: it is
what advance, serialize, and the session store operate on.
type KindId = "story-graph" | "simulation" | "world-graph";
interface GameState {
formatVersion: number; // the shape of THIS envelope — 1, or 2 once the log carries an epoch entry (§10.2)
gameId: string; // from the IdSource port (06 §5.1); opaque to the core
kindId: KindId;
campaignId: string;
campaignVersion: string; // the published version this game runs (§10)
seed: string; // the only randomness state — streams derive from it (§8)
status: GameStatus; // active | ended | abandoned
kindState: unknown; // the kind's own state — opaque to the core
actionLog: LoggedEntry[]; // ordered entries — the replay spine (§9)
}
type GameStatus = "active" | "ended" | "abandoned";
type LoggedEntry = LoggedAction | LoggedContent | LoggedMigration;
interface LoggedAction {
seq: number; // 0-based, monotonic across every entry type
actionId: string; // the action the player submitted
params?: Readonly<Record<string, string | number | boolean>>;
}
interface LoggedContent { // an adoption (16 §3.1)
seq: number; // consumes a seq, exactly like an action
system: "content";
from: string; // the campaignVersion before adoption
to: string; // the campaignVersion after — the last epoch entry's `to` IS campaignVersion
}
interface LoggedMigration { // a migrated load that changed campaignVersion on a log already carrying an epoch entry (§10.2; 16 §3.5)
seq: number; // consumes a seq, exactly like an action
system: "migration";
from: string; // the campaignVersion the save was made under
to: string; // the campaignVersion the migration restamped
}
The log records adoptions, and the starting epoch is derived from it. A game that adopts new
content mid-play (§4, adoptContent; 16-content-epochs.md) appends a LoggedContent entry, so the replay input is
{ seed, starting version, actionLog }, where the starting version is the first epoch
entry's from, or campaignVersion when the log carries none. It is not stored: a
startingCampaignVersion field would duplicate what the log already says. Once a log carries
an epoch entry, every change of campaignVersion is one — an adoption a LoggedContent, a
migrated load a LoggedMigration (§10.2) — so the last epoch entry's to is always the current
version. A migrated log with no epoch entry stays without one; its record's
replayCompatible: false is what keeps it from being replayed. formatVersion is 2 exactly
on a state whose log carries an epoch entry, and 1 otherwise — a game that never adopts
serializes byte-identically to one written before content epochs existed, migrated or not (C26).
What lives here vs in kindState. The envelope holds everything a game has
regardless of kind: identity, campaign reference, seed, status, and the action
log. A kind's own concepts — current node, variables, turn counter, week number,
needs — live in kindState, opaque to the core.
No persisted RNG state. Randomness is derived, not carried: every stream is a pure function of
(seed, streamId)(§8), so the envelope stores the seed and nothing else. A persisted generator state would be written every action and read by nothing — a serialized field free to drift from the derivable truth, taking byte-identical replay with it.{ seed, actionLog }is the complete replay input.
Why
kindState: unknown. The core must not depend on any kind. Typing the field asunknown(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 ownkindStateinternally, guarded bykindId. 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 inGameState— 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 insrc/engine/eslint.config.jsenforces noDate.now.
3. The Kind Interface — The Seam
A kind is engine-owned code that teaches the core how one category of game plays. Every kind implements this interface; the core drives it without knowing which kind it is.
interface Kind<KState> {
readonly id: KindId;
readonly version: string; // manually maintained semver (§10.2, W31)
readonly reasonCodes: readonly ReasonCode[]; // codes this kind adds to the base set (§12)
/** The kind-owned half of the string table: registry assembly merges it alongside the
* core's protected `core.reason.*` set (§10.1), and validation checks it for completeness
* before assembly runs (§11, §12). A kind ships its own messages for the same reason the
* core ships the base set's — the codes are useless to a client that cannot render them.
*
* It must carry a `${id}.reason.<code>` entry for **every** member of `reasonCodes`; the
* completeness check is `registered → has a message` and nothing more. It may carry
* others, and this is a channel rather than a leak: a kind's own engine-created content
* can reference a `LocKey` no campaign collection exists to author — `simulation`'s
* `simulation.finance.investment.label`, on the fixed investment account its `invest`
* resolver creates — and `reasonMessages` is a `Kind`'s only route into the merged
* registry. Such a key is namespaced under the kind like any other and is not a reason
* code; nothing resolves it as one. */
readonly reasonMessages: ReadonlyMap<LocKey, string>;
readonly eventNames: readonly EventName[]; // events this kind may emit (05 §9)
/** Build the starting kind-state for a fresh game of this campaign. `profileData` is this
* kind's own cross-game slice, already resolved and migrated by the session store before
* the pure engine ran (§5, §7.1). It is absent for an anonymous session, for a kind that
* declares no `profileData` member, and for a replay whose `NewGameConfig` carries none —
* a kind that reads it must treat absence as "no cross-game history", never as an error. */
initialState(campaign: Campaign, ctx: KindContext, profileData?: unknown): InitialStateResult<KState>;
/** What the player can do right now — generic actions for the current scene (§6). Takes
* `ProjectionContext` (§3.1): an engine-owned `LocKey` it emits passes through
* `ctx.engineRef` at the point it is produced. */
availableActions(state: KState, ctx: ProjectionContext): AvailableAction[];
/** Render the current situation into a generic scene body (§6). Takes `ProjectionContext`
* for the same reason as `availableActions`. */
scene(state: KState, ctx: ProjectionContext): SceneBody;
/** Resolve one player action. Pure: same (state, action, params, ctx) → same result. */
advance(
state: KState,
actionId: string,
params: ActionParams | undefined,
ctx: KindContext,
): AdvanceResult<KState>;
/** Narrow kind-state to the visible projection for an audience (§9). May return values
* that alias `state`; the core copies at the boundary (§9.1). The one requirement is
* that the result be plain, structurally cloneable data. An engine-owned `LocKey` in the
* result passes through `ctx.engineRef` (§3.1). */
project(state: KState, audience: ProjectionAudience, ctx: ProjectionContext): unknown;
/**
* Tiered content validation of a campaign of this kind (§11). `strings` is the
* registry's built string table — checking a `LocKey` resolves, or that rendered text
* interpolates only a declared, visible variable, needs the table itself, not just the
* kind's opaque `content`.
*/
validateCampaign(campaign: Campaign, strings: ReadonlyMap<LocKey, string>): ValidationResult;
/**
* Whether a `kindState` arriving from outside the engine is one this kind can run against
* `campaign`. The core checks `kindState` for presence only — its shape is the kind's to
* state — so `deserialize` asks here after its own checks (§4), and the save boundary asks
* again after any migration (§10.2). Pure and total over `unknown`: same input, same
* answer, and a throw is read as `false`. Never consulted on a state the engine produced.
*/
validateState(kindState: unknown, campaign: Campaign): boolean;
/**
* A minimal, cross-version-stable terminal identity — published ids only, never
* values ([`07-replay.md`](07-replay.md) §3.3). Every kind returns at least
* `KindOutcome` (§3.2) and may widen it with its own published ids.
*/
outcome(state: KState): KindOutcome;
/**
* How many distinct `terminalId`s (§3.2) a campaign of this kind declares, for progress
* display (§7.3). Optional: a kind whose terminal set is not a bounded, countable
* property of its content omits it, and the core reports no total rather than a wrong
* one. Pure over content — it reads `campaign`, never state, and never a profile.
*/
terminalCount?(campaign: Campaign): number;
/**
* Migrates a `KState` produced under an older `version` forward, when this kind's own
* state shape changed (§10.2). Optional — most version bumps don't change the shape a
* save references. Invoked only at the save-load boundary (`SessionStore`), never by
* `advance`; a missing function on a version mismatch fails the load rather than
* silently handing this version a state it wasn't written to read.
*/
migrateState?(oldState: unknown, fromVersion: string): CommandResult<KState>;
/**
* Whether a running `state` can move from campaign `from` to campaign `to`, two published
* epochs of the same campaign id, and what it becomes (16 §5.4).
* Called by `Engine.adoptContent` after the target campaign's `migrateState` ran. Optional:
* **absent means every session of this kind stays pinned** to the epoch it started on, which
* is the safe default. Pure; reads neither the archive, the channel nor the log; draws no
* randomness. A throw is read as a refusal with `content_incompatible`.
*/
adoptContent?(state: KState, from: Campaign, to: Campaign): AdoptDecision<KState>;
/**
* This kind's cross-game profile slice (§7.1). **Absent means the kind owns no profile
* data at all** — no `KindProfileRecord` is ever written for it, `initialState` never
* receives a `profileData` argument, and nothing about profiles reaches it. That is the
* state every kind is in until it declares this member, and it is the reason the whole
* mechanism is one optional property rather than three correlated ones: a kind cannot
* declare a version without a fold, or a fold without a version.
*/
readonly profileData?: KindProfileData;
/**
* Merges included modules and pack attachments into a host campaign's content (§10.4).
* Optional: a kind without it cannot host an include or receive an attachment, and a
* campaign of that kind that declares either fails with `compose_unsupported`. Called by
* registry assembly **before** `validateCampaign`, once per host, with every module already
* composed depth-first — so it only ever merges one level. Pure over content: the result
* replaces `host.content` in the registry and is what validation and play both see.
*/
composeContent?(
host: Campaign,
modules: readonly ComposedModule[],
attachments: readonly ComposedAttachment[],
): CommandResult<unknown>;
}
/**
* The kind-owned half of §7.1's cross-game data. Engine-owned code, never a host-supplied
* port: it decides what a profile records and how a recorded value reads back, and 06 §2's
* rule admits a host only where it cannot change `serialize()` output. A seeded slice reaches
* `initialState`, so this one can.
*/
interface KindProfileData {
/** The shape version of this kind's `KindProfileRecord.data`. A positive integer, moving
* only when that shape changes. **Deliberately not `Kind.version`**: a kind's code moves
* far more often than its profile slice's shape does, and sharing the stamp would demand a
* profile migration for every unrelated bump. */
readonly version: number;
/**
* Fold one successful action's audit records into this kind's slice. Pure and total — same
* `(current, campaign, changes)` gives the same result, it performs no I/O, and it must not
* throw; the store treats a throw as a refused write (§7.1) rather than letting it reach the
* player, exactly as `resolveSaveEnvelope` already treats a throwing `migrateState`.
*
* **It must be idempotent**: folding the same `changes` twice must equal folding them once.
* That is what makes a transition survive a reload, a branch, or a re-submitted action
* without a duplicate entry, and it is why the recorded shapes below are maxima and sets
* rather than sums.
*
* `current` is `undefined` the first time this profile records anything for this kind.
* Returning a value whose canonical serialization equals `current`'s means "nothing to
* write", and the store skips the write — which is how idempotence becomes observable
* rather than merely claimed.
*/
fold(current: unknown, campaign: Campaign, changes: readonly StateChange[]): unknown;
/**
* Migrate a slice written under an older `version` (§7.1). Optional, and its absence is a
* *degradation* rather than a failure: a version mismatch with no migration drops the slice
* and warns, because a profile is a mirror of games already played and the game is the
* system of record. This is the one place a migration may be missing without failing —
* §10.2's save-side mismatch fails loudly, because there the payload *is* the record.
*/
migrate?(data: unknown, fromVersion: number): CommandResult<unknown>;
}
interface AdvanceResult<KState> {
state: KState; // the new kind-state
status: "active" | "ended"; // advance never yields "abandoned" — that is session-only (§7)
changes: StateChange[]; // audit records (§12) — for history and transparency
messages: OutcomeMessage[]; // player-facing, localized (§12)
error?: ValidationError; // set iff the action was rejected; state is unchanged
}
interface InitialStateResult<KState> {
state: KState; // the starting kind-state
status: "active" | "ended"; // a kind that settles at start may already be ended
changes: StateChange[];
messages: OutcomeMessage[];
}
type AdoptDecision<KState> =
| { adopt: true; state: KState } // the kind-state on the new epoch
| { adopt: false; reason: ReasonCode }; // stays pinned; a base or this kind's own code
Why
AdoptDecisioncarries no changes or messages. Adoption is not play: nothing about it may reach the profile fold (§7.1) or claim a turn happened (C24). A kind that wants the player told renders it in the next scene from state, which the client re-renders anyway.
Why
initialStatereturns a result, not a bareKState. 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 inspectingkindState, which isunknownto it by design (§2), so the kind must say so.InitialStateResultis deliberatelyAdvanceResultminuserror: a campaign is pre-validated before the registry is frozen, so starting a game cannot fail the way an action can.
Why
advancereceivesparams.submitActionwritesparamsinto 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 aValidationErrorif a non-emptyparamsobject arrives. Undocumented parameters are never silently ignored.This holds for every kind, and a kind's contract is its declaration. Each kind's action table (03 §8.2, 10 §4, 12 §6) names the keys each
actionIdaccepts.advancerejects aparamsobject carrying any other key with the kind's ownunexpected_params, before it reads any value. A rejected action is never logged (§4), so every logged action carries only declared keys, which is what lets08-session-capture.md§3.2 copy logged params without filtering them. The declaration is not a member ofKind: a schema the core could read would be a second copy of the validatoradvancealready is (12 §7).
A rejection also returns a message.
errortells the core and client that the action failed and why (ReasonCode,messageKey);messagesis what tells the player — a rejectedAdvanceResultattaches oneOutcomeMessagebuilt from the samemessageKeythe error already carries ({ key: messageKey, visible: true }), so a rejection is never silently swallowed by a client that only rendersmessages. Every kind's rejection path follows this convention (world-graph'sactions/common.tsrejectedhelper 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
actionIdplus 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 —buildcarries a definition, a position and a rotation;advance_tickscarries a tick count (12-world-graph-kind.md§6). The core does not care — it forwards theactionIdand 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
deriveexists. §8 defines fourStreamIdvariants, 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. Withoutderive,ctx.rngwas the only reachable stream and those variants were unreachable by construction.derivecloses over the game'sseedand nothing else: it is pure, it persists nothing, and{ seed, actionLog }remains the complete replay input. The kind that forced this isworld-graph, whose correctness depends on draws keyed by simulated time rather than by how a client batched its requests (12-world-graph-kind.md§5).
ctx.emit is the same shape for the same reasons: a handle scoped to this resolution,
used and discarded, carrying nothing back into state. It reports what the kind is doing to
whatever sink the host attached, and emit returns void precisely so that nothing about
the sink can reach the game (05-observability.md §2). Removing
every event must leave serialize() byte-identical — the determinism harness asserts it
(§14).
The three projecting members — availableActions, scene and project — receive a wider
context; initialState, advance and migrateState never do:
interface ProjectionContext extends KindContext {
/** Maps an engine-owned `LocKey` to the key a response carries for it (§7, response
* aliases). The identity in production unless an authored key in the archive collides
* with an engine key; every engine-owned key under the forced-alias test context. */
readonly engineRef: (key: LocKey) => LocKey;
}
A kind calls engineRef at the point it produces an engine-owned reference — a
world-graph.action.* label, a kind reasonMessages key, a story-graph requirement failure
with no authored requirementFailKey — and never on a key that came from content or
kindState. Aliasing by text match is not available, because the same string can be both
(16 §5.7). The member's absence from KindContext is the enforcement of C29: an alias
cannot reach state when nothing that writes state can produce one.
3.2 KindOutcome — Terminal Identity a Host Can Read
Kind.outcome returned unknown, so a host holding a finished session could learn nothing
from it without knowing which kind produced it. That is the one place the seam leaked its own
purpose: terminal identity exists to be the cross-version-stable vocabulary
(07-replay.md §3.3), and a vocabulary only one reader can parse is not one.
The floor is now typed.
interface KindOutcome {
readonly terminal: boolean; // false while the game is still active
readonly terminalId: string | null; // the published id naming how it ended; null while active
}
terminalId is a published id, and terminal is not derivable from it being non-null —
they are two facts, and a kind that ever ends without naming a terminal keeps them
distinguishable rather than forcing null to mean two things. terminal is the field a host
branches on; terminalId is the field it indexes, stores and compares.
Each shipped kind widens this rather than replacing it, so nothing already in a kind's outcome moves or is lost:
| Kind | terminalId is | Widened with |
|---|---|---|
story-graph | endingId (03 §8.5) | endingId |
simulation | resolution — goals_met / failed / week_limit_reached (10 §12) | resolution, goalsMet, goalsFailed |
world-graph | resolution — objectives_met / failed (12 §8) | resolution, objectivesMet, failureId |
The two columns are not one vocabulary, deliberately. A story-graph terminal is an
authored id drawn from campaign content; a simulation or world-graph terminal is a declared
id drawn from a closed set this contract fixes. Flattening them would mean either inventing
authored resolutions the two mechanical kinds do not have, or collapsing every ending a
campaign ships into three buckets. What a host actually needs is weaker and is what is
guaranteed here: terminalId is stable across engine versions, unique within its campaign,
and safe to persist and compare — which is enough to index a finished session, count distinct
terminals reached (§7.3), and detect a replay divergence, none of which require knowing what
the id means.
A host still may not read a widened field without knowing the kind. That is not a
concession; it is the same rule as kindState (§2). The base is a floor, not a lowest common
denominator that kinds are then discouraged from exceeding.
Balance-sensitive values remain excluded, unchanged. Nothing here loosens
07-replay.md §3.4: no money, no needs, no tick or week counts, no satisfaction
score. KindOutcome adds a boolean and an id, both of which a rebalance leaves alone.
Why not a win/loss disposition on the base. It is the field a host wants next and it was deliberately left out.
Kind.outcomereceivesKStateand no campaign — the same constraint 10 §12 already records forweekLimit— and story-graph's win/loss lives onEndingNode(03-story-graph-kind.md§3), in content the function cannot reach. Supplying it would mean persisting the disposition intoStoryGraphKindState, which is a kind- state shape change: akindVersionbump and aKind.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 readsStoryGraphView.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 theIdSourceport (06-extensibility.md§5.1) closed a real gap it left open:gameIdandseedwere consumed bycreateGamebelow 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 —idsa random source,emitternullEmitter(05-observability.md§4). The older form is recorded rather than deleted, the same "provenance, not authority" relationship this document has withgames/04-engine-specification.mdabove, just one level down.
The pure engine exposes kind-agnostic operations over the envelope. It resolves the
kind by state.kindId, derives the RNG handle, delegates, and reassembles the envelope:
interface Engine {
/** The same `KindRegistry` this engine resolves `state.kindId` against, exposed so a
* caller needing kind metadata outside gameplay — `SessionStore`'s `SaveEnvelope`
* stamping and migration dispatch (§10.2) — reads it off the one engine it already
* holds rather than taking a second, independently-suppliable registry that could
* silently disagree with what this engine actually plays against. */
readonly kinds: KindRegistry;
/** The archive this engine resolves every state's content through (06 §4,
* `EngineHost.archive`) — by default one holding only the construction-time registry.
* Exposed for the same reason as `kinds`: the session store and replay runner read the
* epoch the engine actually plays against, never a second copy. */
readonly content: ResolutionArchive;
/** `campaignVersion` absent → the construction-time registry's version for that campaign
* (the default epoch); present → that epoch, or `unknown_campaign`. `NewGameConfig` does
* not carry it: a version is not something a player chooses (07 §2). */
createGame(config: NewGameConfig, campaignVersion?: string): CommandResult<GameState>;
scene(state: GameState): Scene; // §6
view(state: GameState, audience: ProjectionAudience): PlayerView; // §9
availableActions(state: GameState): AvailableAction[];
submitAction(state: GameState, actionId: string, params?: ActionParams): ActionResult;
previewAction(state: GameState, actionId: string, params?: ActionParams): ActionResult;
serialize(state: GameState): string; // §10 (canonical)
deserialize(data: string): CommandResult<GameState>;
migrate(data: string): CommandResult<GameState>; // §10
/** Move a running state onto another published epoch of its campaign, or say why not
* (16 §5.4). Never a rejection: a refusal is a value, so the
* session store can complete the command it rides on. `campaignVersion` equal to the
* state's → `{ adopted: true, state }`, the same object: no entry, no event
* (`90-decisions.md`, 2026-10-09, S130). */
adoptContent(state: GameState, campaignVersion: string): AdoptionResult;
/** The same engine, with every event stamped for one command
* ([`05-observability.md`](05-observability.md) §6.1). The session store builds a
* short-lived decorator per command and swaps it in here, rather than the pure engine
* ever holding a clock or per-command context of its own. Listed here because this is
* the canonical `Engine` block; 05 §6.1 owns the reasoning. */
withEmitter(emitter: Emitter): Engine;
}
interface ResolutionArchive {
/** The registry the epoch `(campaignId, campaignVersion)` was published in, or undefined.
* One key names one content forever (C23). Pure from the engine's view: the same key
* answers the same registry, or `undefined` once the host has retired it under the
* retention rule (16 §6); a retired key never answers again. The engine caches no
* resolution, so retirement takes effect at the next call. */
resolve(campaignId: string, campaignVersion: string): ContentRegistry | undefined;
/** Every authored key held by any epoch in this archive — the scope response aliases are
* chosen against (§7). Optional: absent means the scope is the construction-time
* registry's authored keys. A host archive that `restore`s legacy epochs must supply it,
* or a restored collision is answered without an alias. */
authoredKeys?(): ReadonlySet<LocKey>;
}
type AdoptionResult =
| { adopted: true; state: GameState }
| { adopted: false; reason: ReasonCode };
/** The reference archive (16 §4.3): both archive and channel, in memory, append-only. */
function createContentArchive(options: {
kinds: KindRegistry;
initial: ContentRegistry; // the first publication — normally the engine's own registry
}): ContentArchive;
interface ContentArchive extends ResolutionArchive, ContentChannel {
/** Refuse a registry without `provenance` (`content_provenance_missing`, §10.1) and any
* authored key the running engine reserves or that contains `~` (`protected_string_key`,
* §12). Validate every campaign against its authored strings united with the running
* engine's catalog (a campaign of an unregistered kind refuses `unknown_kind`), refuse a
* held key whose `epochDigest` differs (`content_version_conflict`), then store
* deep-frozen copies and make this the latest. Returns the epochs newly added; an
* identical republish adds none, still becomes the latest — a rollback — and never
* repoints a held key (`90-decisions.md`, 2026-10-09, S131). */
publish(registry: ContentRegistry): CommandResult<readonly EpochRef[]>;
/** Bring back an epoch a previous process published, from exactly what it persisted.
* Never recomposes. Three outcomes, kept apart (16 §5.1, *Restore*): the stored digest
* does not match `epochDigest(stored.form)`, or the form carries a key reserved when it
* was published (any `core.reason.*` key, or one in `reservedKeys`), or the key is already held with a different digest → `integrity_failure`;
* the running kind's `validateCampaign` rejects the verified form → `unsupported`, and
* nothing is entered; otherwise `restored`. `migrateState` is the campaign's own
* (§10.1), reattached by campaign id because a function is never part of the form.
* Moves neither `latest()` nor `current`. */
restore(stored: StoredEpoch, options?: { migrateState?: Campaign["migrateState"] }): RestoreResult;
/** The persistable form of a held epoch — what `restore` takes back. Undefined for a key
* this archive does not hold. */
stored(campaignId: string, campaignVersion: string): StoredEpoch | undefined;
/** The last publication that succeeded. Not the channel: `current` (below) is per campaign. */
latest(): ContentRegistry;
/** The channel member: the version of `scope.campaignId` in the last successful
* publication that held that campaign, or undefined if none did. */
current(scope: ContentScope): string | undefined;
authoredKeys(): ReadonlySet<LocKey>;
}
interface EpochRef {
campaignId: string;
campaignVersion: string;
}
/** An epoch's identity (C28): exactly what is hashed, and exactly what a host persists. */
interface EpochForm {
/** The composed campaign: its `content` already composed, `includes` kept as
* provenance, and no `migrateState`. */
campaign: Omit<Campaign, "migrateState">;
/** The attachments applied to this campaign, in composition order (§10.4). */
attachments: readonly CampaignAttachment[];
/** Authored strings only — `ContentRegistry.provenance` for this campaign (§10.1). No
* engine-owned message, and no string of a campaign it does not compose. */
strings: Readonly<Record<LocKey, string>>;
}
/** Recorded beside the form at publication; not hashed. */
interface EpochPublication {
kindId: KindId;
kindVersion: string;
/** The explicit keys the running engine reserved when it accepted the form (§12), sorted.
* `restore` tolerates an authored key reserved now only if it is absent here. */
reservedKeys: readonly LocKey[];
}
interface StoredEpoch {
form: EpochForm;
publication: EpochPublication;
digest: string; // epochDigest(form)
}
type RestoreResult =
| { outcome: "restored"; ref: EpochRef; warnings: readonly ValidationWarning[] }
| { outcome: "unsupported"; ref: EpochRef;
rejectedBy: { kindId: KindId; kindVersion: string };
publishedBy: { kindId: KindId; kindVersion: string };
errors: readonly ValidationError[];
warning: ValidationWarning } // epoch_unsupported
| { outcome: "integrity_failure"; ref: EpochRef; errors: readonly ValidationError[] };
/** `canonicalDigest` over the form. Exported so a host recomputes it without recomposing. */
function epochDigest(form: EpochForm): string;
/** `sha256Hex(canonicalize(value))` (§10) — the one digest helper a host uses for identities
* the engine states, such as an extension set's (16 §3.4). */
function canonicalDigest(value: unknown): string;
An epoch is its authored form (C28; 16 §3.4). publish builds each campaign's EpochForm
from the registry's provenance, never from the merged strings, so a registry that also holds
other campaigns, or an engine whose message wording changed, publishes the same digest for
unchanged content. Engine-owned messages come from the running catalog whenever a registry is
assembled, including one restore rebuilds: historical authored text is preserved, and
historical engine-error wording is not promised.
restore's warnings are historical_string_collision, one per authored key the running engine
reserves that the form's reservedKeys did not, with
details: { campaignId, campaignVersion, key }. The authored text stays at its key; the engine's
text for the same key reaches clients through a response alias (§7). unsupported carries
epoch_unsupported with details: { campaignId, campaignVersion, potentialRegression },
potentialRegression true when the epoch's kind is registered and rejectedBy equals
publishedBy — the same kindId and the same kindVersion — a rejection by the version that published the epoch is a regression to
investigate, not an accepted break. The classification is not persisted: the next process
validates again.
createContentArchive refuses an initial registry that fails validation the same way
publish does, by throwing — a construction error, like every other one this section names.
Every state-taking operation resolves content by the state. scene, view,
availableActions, submitAction, previewAction, deserialize, migrate and adoptContent
look up content.resolve(state.campaignId, state.campaignVersion) and hand that registry to the
kind as KindContext.registry and its campaign as KindContext.campaign (§3.1). The
construction-time registry is the default epoch — the one createGame starts on when no
version is asked for — not the only content. An EngineHost.archive that does not resolve every
campaign of registry at its registered version is a construction error, the treatment a
missing kind gets. With no archive supplied, every behaviour below is unchanged.
createEngine reads registry's campaign map once: the default archive and the version
createGame starts on both come from that copy, so a later mutation of the registry changes
neither. An archive answer that holds the campaign at a version other than the one asked for
counts as unresolved — unknown_campaign wherever a version is resolved, and a construction
error for the default epoch (90-decisions.md, 2026-10-09, S129).
submitAction is the whole loop, in the core:
submitAction(state, actionId, params):
1. kind = kinds[state.kindId]; seq = state.actionLog.length // 0-based, monotonic; counts epoch entries too
registry = content.resolve(state.campaignId, state.campaignVersion) // by state, above
campaign = registry.campaigns[state.campaignId]
2. handle = rngHandleFor(state.seed, { kind:"action", seq }) // §8 — derived, not carried
3. emit = resolutionEmitter(emitter, state.gameId, seq) // 05 §4 — ordinal starts at 0
4. result = kind.advance(state.kindState, actionId, params,
{ registry, campaign, rng: handle, derive, seq, emit }) // §3.1 — `derive` closes over the seed
5. if result.error → return { ok:false, errors:[result.error] }, state unchanged // ActionResult.errors is a list (§12)
6. newState = {
...state,
kindState: result.state,
status: result.status,
actionLog: [...state.actionLog, { seq, actionId, params }],
}
7. return { ok:true, value:newState, errors:[], warnings:[],
changes:result.changes, messages:result.messages }
previewAction runs that same path against a null emitter. Its successful value is a
prospective state for rendering only: the caller must project it and discard it, never
persist it. The input state remains unchanged and preview emits no action lifecycle event,
so it cannot masquerade as a committed command.
A rejected action does not advance
seq. Step 5 returns without appending, so the next attempt computes the sameseqfrom the same log length. That is deliberate — the log is the replay spine and a refused action is not part of it — but it means two rejected attempts emit events with identical(gameId, seq, ordinal). Observability states that limit rather than papering over it, and disambiguates at the boundary (05-observability.md§5, §6).
Immutability is unconditional (games/04-engine-specification.md §11.3): every operation returns a new envelope.
Its converse holds too: the engine owns what it logs. submitAction and previewAction
copy params once on entry, and both step 4's advance and step 6's LoggedAction receive that
copy, so a caller mutating its own object afterwards cannot rewrite the replay spine.
ActionParams is a flat record of primitives, so a shallow copy is the whole value.
deserialize resolves a state against this host, or refuses it. A shape-valid envelope
is rejected with unknown_campaign when its campaignId is not in the registry, with
unknown_kind when its kindId is not registered, and with invalid_state when both resolve
but kindId is not that campaign's own kindId. Last, the kind judges the payload:
Kind.validateState(kindState, campaign) (§3) returning false, or throwing, rejects with
invalid_state at path: "kindState". Each rejection emits core.deserialize.rejected
(05-observability.md §8), and migrate inherits all four. The kind
agreement check is the raw path's copy of a cross-check the save path already makes (§10.2).
Without it, a state could hand one kind's advance another kind's campaign; without the last,
a kindState of the wrong shape reaches advance and fails there, as a throw or a
NaN, rather than at the boundary.
campaignVersion resolves through the archive, and a log's epoch entries must agree with
it. unknown_campaign also covers a campaignId the archive holds at no epoch matching
campaignVersion: a state is played against the content it names or not at all, never silently
against whatever this host registered under the id (C22). Three invalid_state refusals guard
the log: a formatVersion: 1 state whose log carries an epoch entry, a formatVersion: 2 state
whose log carries none (90-decisions.md, 2026-10-09, S130), and a state whose last epoch
entry's to is not its campaignVersion (C26). A migration entry's from is not resolved: it
names an epoch this host could not run, which is why the migration happened. A formatVersion above 2 refuses as every
unknown version does. This closes the gap an earlier revision recorded here as known and
retained — a state serialized under one resolution and accepted under another
(90-decisions.md, 2026-10-08).
createGame assembles the envelope and delegates the start to the kind:
createGame(config, campaignVersion?):
0. gameId = ids.newGameId() // 06 §5.1 — the IdSource port
1. version = campaignVersion ?? registry.campaigns[config.campaignId].version
campaign = content.resolve(config.campaignId, version)?.campaigns[config.campaignId]
// missing → unknown_campaign; kind = campaign.kindId
2. seed = config.seed ?? ids.newSeed() // 06 §5.1 — recorded in the envelope
3. startHandle = rngHandleFor(seed, { kind:"system", system:"start", seq:0 }) // §8
4. startEmit = resolutionEmitter(emitter, gameId, 0) // 05 §4 — seq 0, ordinal 0
5. init = kind.initialState(campaign, { registry, campaign, rng: startHandle, derive, seq: 0, emit: startEmit })
// a kind that settles at start (story-graph, 03 §8.2) draws its initial
// random transitions from startHandle, and reports "ended" if it settled to one
6. return the envelope { kindId: campaign.kindId, campaignId: campaign.id,
campaignVersion: campaign.version, seed,
status: init.status, kindState: init.state, actionLog: [] }
// init.changes / init.messages ride out on the CommandResult
The start resolution uses seq: 0 for both the RNG stream and the emitter, matching the
first action's numbering; the two never collide because the stream is system:"start"
rather than action (below), and because the emitter's ordinal restarts per resolution.
The start stream (system:"start") is deliberately distinct from the per-action
streams submitAction uses ({ kind:"action", seq }), so a start-of-game random draw
can never collide with an action's — the initial settle is reproducible on its own stream.
5. Configuration
interface NewGameConfig {
campaignId: string;
seed?: string; // omitted → the store generates one and records it
audience?: ProjectionAudience; // default "player"
kindProfileData?: unknown; // §7.1 — the resolved cross-game slice, not the profile
}
The kind is not named here — it is a property of the campaign (Campaign.kindId),
resolved from the registry. A client starts a game by campaign; whether that campaign
is a story graph or a simulation is invisible to it.
kindProfileDatais 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 thatprofileIdnever appears on this type or inGameState. 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 timeadvanceruns, whatever the kind kept from the slice is ordinarykindState.Putting it anywhere else breaks the replay oracle.
ReplayFixture.configis aNewGameConfig(07 §2), so a value carried here is captured with the fixture and reproduces; a value reachinginitialStatethroughKindContextinstead 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 objection90-decisions.mdrecords against feeding a profile intoKindContextfor 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 asActionParams, and neverprofileId.The type is
unknownfor the same reasonGameState.kindStateis (§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-graphuses it as a gated choice list — anAvailableActionis a node choice,available/reasonKeycome 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.simulationreturns its four verbs (plan.add/plan.remove/plan.clear,end_week) and pushes the whole parameter domain — whichActionTypes are offerable, and the plan itself, so a client can compute a validplan.removeindex — intoSimulationView.plan(10 §9).world-graphdoes 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), becausebuild× every definition × every cell × four rotations is not a list.
The invariant across all three is only this: whatever a client can submit, it can discover
— from availableActions, from the projection, or from both. AvailableAction is one of
the two places that discovery may live, not the place it must.
7. The Session Store and the Platform API
The pure engine is stateless. The session store is the thin stateful layer clients
actually call. It maps the architecture's §10 API onto the pure engine, keyed by
sessionId.
The surface splits cleanly into queries (read-only, no persisted state change) and commands (advance or persist). This is a documentation convention for clarity — not CQRS the pattern: there is one state model, no separate read store, no event bus. Just a useful line between "look" and "change."
interface SessionStore {
// ── Queries (read-only) ──────────────────────────────
listCampaigns(profileId?: string): Promise<CampaignCatalog>; // session-free — §7.3
getScene(sessionId: string): Promise<Scene>;
getView(sessionId: string): Promise<PlayerView>;
getStrings(sessionId: string): Promise<StringTable>; // resolve LocKeys — below
listSaves(profileId: string): Promise<readonly SaveSummary[]>; // player-keyed, totally ordered — §7.4
previewAction(sessionId: string, actionId: string, params?: ActionParams): Promise<SessionActionResult>; // resolves prospectively, then discards
// ── Commands (advance or persist) ────────────────────
createSession(config: CreateSessionConfig): Promise<SessionHandle>; // profileId lives here
resumeSession(sessionId: string): Promise<Scene>;
submitAction(sessionId: string, actionId: string, params?: ActionParams): Promise<SessionActionResult>;
saveGame(sessionId: string): Promise<SaveHandle>; // named/manual save
loadGame(saveId: string): Promise<SessionHandle>;
deleteSave(profileId: string, saveId: string, expectedSavedAt: string): Promise<void>; // §7.4
branchSession(sessionId: string, atActionCount: number): Promise<SessionHandle>; // §7.4
}
interface SessionHandle { sessionId: string; scene: Scene; }
interface SaveHandle { saveId: string; savedAt: string; savedAtSeq: number; }
/** §7.4. Save-list metadata only — never a blob, never an envelope, never kind content. */
interface SaveSummary {
saveId: string;
campaignId: string;
savedAt: string; // Clock-stamped (06 §5.4); the primary sort key
savedAtSeq: number; // actions logged at the moment the save was taken
}
/** §7.3. `strings` resolves every `LocKey` the summaries carry, and nothing else. */
interface CampaignCatalog {
readonly campaigns: readonly CampaignSummary[];
readonly strings: StringTable;
}
interface CampaignSummary {
campaignId: string;
kindId: KindId;
titleKey: LocKey;
progress?: CampaignProgress; // present iff `listCampaigns` was given a `profileId` — §7.3
}
interface CampaignProgress {
discovered: number; // distinct `terminalId`s this profile has reached here
total: number; // what `Kind.terminalCount` reports for this campaign
}
interface CreateSessionConfig extends NewGameConfig {
profileId?: string; // omitted → anonymous session; see §7.1
}
/** What a client gets back from an action. Never the envelope. */
interface SessionActionResult {
ok: boolean;
scene?: Scene; // the new scene, on success — a projection (§9)
errors: ValidationError[];
warnings: ValidationWarning[];
changes: StateChange[]; // audit records; the store returns only `visible: true` (§12)
messages: OutcomeMessage[]; // likewise, only `visible: true`
}
type StringTable = Readonly<Record<LocKey, string>>;
submitActionreturnsSessionActionResult, notActionResult.ActionResultextendsCommandResult<GameState>(§12) — its success value is the envelope, seed and action log and opaquekindStateincluded. 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 aScene.
The store gates
visible, for every audience.submitActionandpreviewActiondrop everyStateChangeandOutcomeMessagewhosevisibleisfalse, on accept and reject alike and foraias well asplayer: a gate left to the client hides nothing from the client. The engine'sActionResultstays complete. It is the audit surface the profile fold (§7.1), replay and observability read, and the store folds the profile from it before it projects the result.
createSessiontakesCreateSessionConfig. It previously tookNewGameConfig, which carries noprofileId— leavingCreateSessionConfigdefined and unreachable, and no way for a client to start the profiled session MVP §5 requires for cross-session achievements.profileIdstays offNewGameConfigand out ofGameState(§7.1); it is a session input, which is exactly what this type is for.
Why
getStringsis a store operation. Every client-facing type carriesLocKeys —Scene.actions[].labelKey,CampaignSummary.titleKey,OutcomeMessage.key,ValidationError— and a client that may call nothing but this store (09 §2) otherwise has no way to render any of them. Resolving them inside the DTOs was the alternative and is worse: it would bake a locale into the projection and lose the property that clients never string-match English (§12). The table is keyed by the session's campaign, its live epochs (below, C27) and the locale the session was created with; a locale switch is a new session, which is all the MVP's single locale needs. Its engine-owned entries come from the running engine and its authored ones from the epochs, under the response aliases below.
Dropping a session from memory is a host operation, not a store one. createSessionLayer
returns SessionLayer (10-design.md 06 §4), which is this SessionStore plus
forget(sessionId): it waits for any command holding the session and any persistence read in
flight, then drops the cached record without a write. A read that began before forget and
settles after it is returned to its caller but not cached. It never changes serialize(). It is
kept off SessionStore so the client surface and the coverage checklist (09 §4) stay one-to-one:
no client should be able to call it.
Capturing a session as a fixture is a host operation for the same reason, and a stronger one.
SessionLayer.captureSession(sessionId, name) returns the session as a ReplayFixture
(10-design.md 07 §2, 08 §2.1). It is read-only and shares branchSession's refusals (§7.4,
Capturing a session). The fixture carries the seed, which never reaches a client (§6). If a
client could call capture, it could read every future draw in its own session.
The store persists the envelope (§2) and nothing else about play. Wall-clock
timestamps, owner ids, and other host metadata live on the store's record, outside the
replayable GameState. This is the boundary that keeps determinism intact while still
supporting "resume on another device" (architecture §2).
createSession generates and records a seed when the config omits one, so a resumed or
replayed session is always reproducible.
Two independent lock domains, and they are part of this contract. The store holds a serialized blob per session and mutates it in place, so "read the blob, resolve, write it back" is a read-modify-write and needs saying who may run concurrently with whom:
- Per
sessionId— every operation that touches one session's blob queues behind its predecessor for that session. Two submissions against the same session therefore resolve in the order they acquire the lock, never interleaved, so neither can read a blob the other is about to overwrite. - Per
profileId— the profile upsert (§7.1) is its own load-merge-save, and two different sessions may legitimately share oneprofileId; that is what a profile is for. Session locking alone does not serialize it, so profile upserts queue on a second, independent domain keyed byprofileId. - Per
saveId— a save record is written bysaveGameand removed bydeleteSave(§7.4), and neither is reachable from the session lock: a save outlives the session that wrote it, anddeleteSaveis addressed bysaveIdalone. A third domain keyed bysaveIdis 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.
previewActiontakes the session lock but is not a command. It shares the per-session queue, so it cannot evaluate one version of a session while a neighbouring submission persists another. Everything else that makes an operation a command, it deliberately skips: it does not increment the attempt counter, does not write the blob, does not touch profile persistence, and emits no action lifecycle event (§4). That is the query/command split above taken literally — a preview is a read that happens to run the write path, so it must be ordered like a write and recorded like a read.
New content reaches a running session at four adoption points, and nowhere else (C25;
16 §5.3). A host offers content through an optional
SessionHost.content: ContentChannel (06 §4):
interface ContentChannel {
/** The campaignVersion a session in this scope should be on, or undefined for no offer.
* Called under the session lock. A throw is read as no offer. */
current(scope: ContentScope): string | undefined;
}
interface ContentScope {
campaignId: string;
sessionId: string;
profileId?: string; // absent for an anonymous session
}
| Point | When | Persisted in |
|---|---|---|
submitAction | After the action commits, before the scene is projected | The accepted action's write |
resumeSession | Before the scene is returned | Its own write |
loadGame | After the save resolves, before the scene is returned | The new session's first write |
branchSession | After the retained prefix replays, before the scene is returned | The branch's first write |
At each, under the session lock, the store asks the channel; if it names a version other than
the state's, it calls engine.adoptContent(state, version) and persists and projects whichever
state results. A refusal leaves the session pinned and the command completes on the old epoch,
so adoption never fails a command — nor does a failed adoption-only write at
resumeSession: the store restores the unadopted state, returns its scene, and the next
adoption point offers again. Queries never adopt, a rejected action never adopts, and
previewAction never consults the channel. createSession asks the channel once for the version
to start on and passes it to createGame(config, version); that is creation, not adoption, and
logs no content entry. It takes the kind and profile data from the offered epoch; an offer the
archive does not hold fails creation unknown_campaign, and an answer that is not a string is
no offer. At submitAction, the profile fold and the action's messages use the epoch the action
resolved on, not the one adopted after it (90-decisions.md, 2026-10-09, S133). With no channel, no session is ever offered content, and every operation
above behaves as it did before content epochs existed.
Every projection resolves through the session's current epoch. getStrings returns the
union of the string tables of the session's live epochs — its starting version, or the last
migration entry's to when the log carries one, and every content entry's to after it — each
key resolved from the most recent of those epochs that defines it. The current epoch wins every
key it has; a key it dropped still resolves through the epoch that last had it; a session that
never adopted gets exactly its one epoch's table. So the table only grows, and any key a result
for this session carried resolves in every later getStrings (C27) — including a submitAction
that adopts after commit, whose messages come from the old epoch and whose scene from the new.
A client whose cached table lacks a key fetches it again. A live epoch the archive no longer
resolves contributes nothing, and the union of the rest is returned (90-decisions.md,
2026-10-09, S133). What the union does not promise is a
key's earlier wording: a key both epochs define resolves to the newer text
(16-content-epochs.md §5.7).
Engine-owned keys resolve from the running engine, not from an epoch (C28). The epochs in the
union contribute only their authored strings; every engine-owned key a response can carry — the
core's core.reason.* set and each registered kind's reasonMessages — resolves to the running
catalog's text. Where an authored key equals an engine key, which only a restored epoch can hold
(§4, historical_string_collision), the two meanings travel under different keys:
- A response alias.
alias(k)is~kif no authored key in scope (ResolutionArchive.authoredKeys, §4) equals it, else~n~kfor the smallest positivenwhose form is free. Engine keys contain no~(§12), so distinct engine keys never share an alias, and an authored entry is never renamed, dropped or overwritten to make room. The scope is archive-wide, so the map changes only whenrestoreadds an epoch or a host archive retires one (16 §6). Where the archive offers noauthoredKeys, the scope is the construction registry's authored keys: itsprovenancewhen every campaign records one, else every key of itsstringsthe running catalog does not own. That fallback cannot see an authored key equal to an engine key, which only a restored epoch holds, and an unprovenanced registry cannot be published (§10.1), so it never hides a collision. - Which references take one. In production, an engine key
kis aliased only when some epoch in scope authorsk; every other engine reference is emitted unchanged. UnderaliasEngineKeys: "all"(06 §4), a test context, every engine-owned reference is aliased. The alias is applied where the engine-owned reference is produced —ProjectionContext.engineRefinsideavailableActions,sceneandproject(§3.1), and the store's response builder forerrors,warningsandmessagesafter the profile fold and logging consumed them unaliased — never by matching text. The ownership table below says which producer owns which key. - One map, every surface. Scenes, views, available actions, previews, errors, warnings,
messages,
getStrings,listCampaigns'stringsand an offline bundle use the same map. A table holds the authored strings of the live epochs behind the response, each unaliased engine key with the running text, and each alias with the running text — so for a collidingk, entrykis the authored text and entryalias(k)the engine's. - Aliases are presentation, not identity. A client must not persist an alias apart from the
table it arrived in. No alias reaches
GameState, an action log, the profile fold orserialize()(C29).
| Producer | Key | Owner |
|---|---|---|
Core rejections, ValidationErrors and store warnings | core.reason.${code} | engine |
A kind's resolve and validation, from reasonMessages | ${kindId}.reason.${code} | engine |
Story-graph scene reasonKey, and its requirement-failure error | requirementFailKey if the choice sets one, else core.reason.requirement_unmet | per value: authored, else engine |
| World-graph action labels and scene text | world-graph.action.*, world-graph.scene.* | engine |
| Simulation's fixed investment account | simulation.finance.investment.* | engine |
World-graph building_broken and scenario_resolved alerts' titleKey / messageKey | world-graph.alert.* | engine |
Any LocKey read from content or kindState — titleKey, nameKey, an incident_active alert's titleKey / messageKey, descriptionKey, displayNameKey | as authored | authored |
OutcomeMessage.key | per producer, by the rows above | per producer |
A mixed-origin field marks each value's owner on an internal carrier between the producer and the response builder; it is not exported, not serialized, and never part of a public type.
7.1 The Profile Store
Achievements must outlive a game (MVP §5, 03 §7), but nothing durable may sit inside
GameState. So the profile is a second store beside the session store, at the same
layer — stateful, I/O-doing, and invisible to the pure engine.
interface PlayerProfile {
formatVersion: 3;
profileId: string;
achievements: readonly AchievementRecord[];
terminals: readonly TerminalRecord[]; // §7.3 — the cross-session half of campaign progress
kindData: readonly KindProfileRecord[]; // below — the kind-owned cross-game slice
}
interface AchievementRecord {
campaignId: string; // achievement ids are only unique within a campaign
achievementId: string;
}
interface TerminalRecord {
campaignId: string; // a terminalId is only unique within a campaign (§3.2, §17)
terminalId: string; // `KindOutcome.terminalId` — a published id, never a value
}
/** At most one per kind. Ordered by `kindId` ascending, so a profile has one serialization. */
interface KindProfileRecord {
kindId: string; // a `KindId` for every kind this build knows; see below
dataVersion: number; // the `Kind.profileData.version` this `data` was written under
data: unknown; // opaque to the core, exactly as `GameState.kindState` is (§2)
}
type ProfileWarningCode =
| "profile_missing" | "profile_corrupt" | "profile_write_failed"
| "profile_kind_data_unreadable" | "profile_kind_data_rejected";
interface ProfileWarning {
code: ProfileWarningCode;
profileId: string;
kindId?: string; // present iff the warning is about one `KindProfileRecord`
}
interface ProfileLoadResult { profile: PlayerProfile; warnings: readonly ProfileWarning[]; }
interface ProfileSaveResult { ok: boolean; warnings: readonly ProfileWarning[]; }
interface ProfileStore {
load(profileId: string): Promise<ProfileLoadResult>;
save(profile: PlayerProfile): Promise<ProfileSaveResult>;
}
Rules, all of them determinism-preserving:
- Profile identity is a session concern.
profileIdlives onCreateSessionConfigand the store's record — never onNewGameConfig, never onGameState. The pure engine has no idea profiles exist. A manual save preserves that association the same way:profileIdround-trips through the store's own save record, never through the serializedSaveEnvelope, soloadGamerestores the same profile asaveGame'd session had. - Nothing in resolution reads a profile. A kind unlocks into its own
kindState(03 §7) and emits anachievement_unlockedStateChange(§12). After a successful action, the session store idempotently upserts those records through theProfileStore. Profile contents and write outcomes never feed back intoadvance. - 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: 3profile plusprofile_missing/profile_corrupt. A failed write returnsprofile_write_failedand 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.statusisended, the session store readsKind.outcomeand idempotently upserts oneTerminalRecordfor theterminalIdit names, on the same write as the achievement upsert. AnullterminalIdon a terminal outcome records nothing. Nothing inadvanceorprojectever 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 oneKindProfileRecord; the store folds it forward after a successful action, and hands it back throughNewGameConfig.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: 3is a literal type, and the break is deliberate. A host constructing aPlayerProfilefails to compile rather than writing a2document 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
kindIdorder, on the sameProfileStore.saveas 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, anundefinedin a value position) — refused. The previousdatais retained untouched and oneprofile_kind_data_rejectedwarning names the kind.foldis content-adjacent code and is invoked defensively for exactly the reasonresolveSaveEnvelopealready invokesmigrateStatedefensively.
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.migrateruns and its result is used. A missingmigrate, a failed one, or one that throws drops the slice for this session and warnsprofile_kind_data_unreadable.dataVersion > …— an older build reading a newer profile. The slice is not read, and the same warning is raised.
A dropped slice is not a deleted one. The record stays in the profile byte for byte, so the build that can read it still can; the kind simply starts that session with no cross-game history, which is the same degradation a missing profile already gets.
Migration order, and why it is this way round. The profile's own formatVersion migration
runs first, then the per-record dataVersion migration. Same reasoning as §10.2's
kind-then-campaign order: the envelope's shape is a precondition for addressing the payload
inside it. The split of who runs each is the load-bearing part — formatVersion is the
adapter's, because the adapter is what reads the stored document and it needs nothing else;
dataVersion is the core session store's, because it needs the kind registry, and a host port
must never hold one.
Unknown kinds are preserved, never dropped. A KindProfileRecord whose kindId names no
kind in this build's registry is carried through load and save unchanged: not migrated, not
sized, not handed to any kind, not counted against anything. kindId is typed string rather
than KindId for exactly this reason — a closed union would make such a record fail shape
validation and condemn the whole profile as profile_corrupt. The case is real and
one-directional: a host running a subset of kinds, or an older build, must not erase a player's
progress in a kind it happens not to have.
Reading — once, at createSession, and nowhere else. When createSession is given a
profileId and the campaign's kind declares profileData, the store loads the profile,
resolves the record for that kindId, migrates it, and passes the result as
NewGameConfig.kindProfileData (§5) — before the pure engine runs. resumeSession, loadGame
and branchSession do not re-seed: the kind already put whatever it kept into kindState
at initialState, and re-reading a profile that has moved on since would make a resumed session
differ from the one that was saved. An anonymous session reads nothing, so it seeds nothing.
A branch replays the recorded value, never the profile. branchSession runs initialState
again (§7.4), so it needs the exact kindProfileData the source was created with. The store
keeps an owned copy of it on the record as initialization (§7.2) and passes that copy to the
branch. Reading the profile again would seed the branch from data the source never saw. Passing
nothing would seed it as an anonymous game. Either way the branch would diverge from its prefix
at the first state.
Invariants
Each is written to become an assertion. The session store maintains P1–P6; P7 and P8 are
obligations on a kind that declares profileData, and the store cannot enforce them for it —
which is why they are stated here rather than assumed, and why the kind conformance tests are
where they are checked.
- P1. Nothing a profile holds is readable from
advance,project,scene,availableActionsoroutcome. The only route in isinitialState's third argument. Enforced by the seam's own shape: no other member is given one. - P2.
serialize()output for a session created withprofileIdset andkindProfileDataabsent is byte-identical to one created anonymously with the sameNewGameConfig. Enforced by code, and by the determinism harness (§14). - P3. A
KindProfileRecordwhosekindIdis unregistered in this build survives a load-modify-save round trip byte-identically. Enforced by code. - P4. No
ProfileStorefailure, refused fold, dropped slice, or size rejection changesAdvanceResult,GameState, or whethersubmitActionsucceeded. Every one of them surfaces as aValidationWarningand nothing else. Enforced by code — the same guaranteeprofile_write_failedhas held since W8. - P5.
PlayerProfile.kindDataholds at most one record perkindId, ordered bykindIdascending, 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.
foldis pure: no I/O, no ambient clock, no randomness, and the same(current, campaign, changes)always returns the same value. Enforced bysrc/engine/eslint.config.js's determinism guard for the mechanical half, by instruction for the rest. - P8.
foldis idempotent:fold(fold(c, k, ch), k, ch)is canonically equal tofold(c, k, ch). Enforced by a kind conformance test, and made observable in the store by P6 — a non-idempotent fold shows up as a second write with no second action.
7.2 Host Persistence — The Record Store Beneath the Session Store
§7 says the store keeps host metadata "on the store's record" without naming that record as a type. It is one now, because a host supplies it: the session store is core-owned — locking, stamping, save-envelope assembly and profile upsert all live here — and what a host may replace is the narrower job of reading and writing the records underneath.
/** Host-owned. Deliberately outside GameState, and never replayed. */
interface StoredSessionRecord {
sessionId: string;
blob: string; // the canonical serialization (§2), never a live object
audience: ProjectionAudience;
attemptCounter: number; // telemetry stamping only (05 §6) — never compared by an adapter
revision: number; // the committed-write counter, below — 0 when first written
replayCompatible: boolean;
createdAt: string; // Clock (06 §5.4), never Date.now
updatedAt: string;
profileId?: string; // §7.1 — round-trips here, never through SaveEnvelope
initialization?: SessionInitialization; // absent → written before the field existed: unknown
}
/** What `createSession` handed `initialState` beyond `{ campaignId, seed }` (§7.1). */
interface SessionInitialization {
kindProfileData?: unknown; // absent inside a present wrapper → recorded, and there was none
}
interface StoredSaveRecord {
saveId: string;
campaignId: string; // host-side routing only; the authority is the embedded GameState
blob: string; // a serialized SaveEnvelope (§10.2)
savedAt: string; // Clock (06 §5.4), never Date.now — §7.4's sort key and delete precondition
savedAtSeq: number;
audience: ProjectionAudience;
profileId?: string;
initialization?: SessionInitialization; // copied from the session record, and back onto it by loadGame
}
interface SessionRecordStore {
get(sessionId: string): Promise<StoredSessionRecord | undefined>;
put(record: StoredSessionRecord): Promise<void>;
}
interface SaveRecordStore {
get(saveId: string): Promise<StoredSaveRecord | undefined>;
put(record: StoredSaveRecord): Promise<void>;
/** §7.4. Every record this adapter holds for one profile, in any order — the store sorts. */
listByProfile(profileId: string): Promise<readonly StoredSaveRecord[]>;
/** §7.4. Conditional: remove `saveId` only while its stored `savedAt` still matches. */
delete(saveId: string, expectedSavedAt: string): Promise<void>;
}
interface SessionPersistence {
sessions: SessionRecordStore;
saves: SaveRecordStore;
}
revision is what a multi-writer adapter compares; attemptCounter never is. The two
count different things. attemptCounter stamps telemetry (05 §6), and submitAction advances it
before dispatch, so a rejected submission moves it with no write behind it. revision is 0
when a record is first written by createSession, loadGame or branchSession — an adoption
riding on that first write leaves it at 0. It rises by exactly one on every accepted
submitAction write, whether or not an adoption rides on it, and on every resumeSession that
adopts; a rejection, a preview, and a resumeSession that adopts nothing never move it.
The rule is stated here so no adapter has to infer one:
An adapter that detects concurrent writers accepts a
putfor an existingsessionIdonly when its storedrevisionequals the incomingrevision - 1. Otherwise it throws aSessionPersistenceConflict-branded error (below). An adapter that comparesattemptCounterinstead refuses every write after a rejected action, and strands the session.
initialization is the input a branch cannot recover from the blob. createSession writes
it on every record: { kindProfileData } holding an owned copy of the resolved slice, or {}
when the session was anonymous or its profile held no slice for the kind. Later changes to the
object the ProfileStore returned therefore cannot reach it. saveGame copies it onto the save,
loadGame copies it back onto the new session, and a branch inherits its source's. An adapter
stores it and returns it unchanged. A missing wrapper and an empty one mean different things,
and an adapter must keep them apart. {} says the game was created with no profile data. A
missing field says the record predates this field, so its creation input is unknown. Reading a
missing kindProfileData as "none" would branch a profiled game as an anonymous one. The field
sits on the record and not in GameState, because it is host input like profileId.
serialize() output, GameState.formatVersion and every fixture are unchanged by it.
Omitted → in-memory, which is the MVP default. Without persistence the store's own maps
are the storage. With it, the store caches sessions and consults persistence only on a miss,
bounded by the host's sessionCacheLimit when one is set (10-design.md 06 §5.2). It holds no
saves at all: loadGame, deleteSave and listSaves read the adapter every time. Either way a
host adapter is a durability layer, not a replacement for the store's bookkeeping.
campaignId on the save record is host-side routing, nothing more. A host that lists "your
saves for this campaign" needs it without deserializing every envelope. It is a copy of what
the embedded GameState already says, and §10.2's cross-checks at load read the embedded value,
not this one — so a divergent copy can misroute a listing but can never load the wrong game.
saveIdis the lookup key onSaveRecordStore.get(saveId)andput(record)address the same record, so an adapter that stores under any other key silently makes every save unretrievable — the write succeeds, the read misses, and nothing fails loudly. Stated because it is exactly the defect the first adapter shipped with.
Where sessionId and saveId come from. Both are minted by the session layer, and both are
seamed: RecordIdSource (06-extensibility.md §5.7) is supplied on
SessionHost and, when present, replaces the layer's own crypto.randomUUID() at exactly the
three call sites that mint one — createSession, loadGame, and saveGame. It is a port
separate from IdSource (06 §5.1) because these two ids are the ones that never enter
GameState: they key the records above, which is host metadata by construction, whereas
gameId and seed are serialized replay inputs. Omitted, behaviour is byte-identical to the
unseamed minting it replaced — which is what makes it additive rather than a change to §7.
Failures are SessionStoreError, not CommandResult. None of SessionStore's methods
carry an error channel — SessionHandle, SaveHandle and Scene have nowhere to put one — so
these stay exceptions. What they are not is opaque:
type SessionStoreErrorCode =
| "unknown_session" | "unknown_save" | "storage_failure" // this section
| "concurrent_modification" // this section, below
| "unknown_campaign" | "invalid_state" | "unknown_kind" // §12, the kernel's own
| "save_requires_migration" | "migration_failed"; // §12, the save boundary's
class SessionStoreError extends Error {
readonly operation: string;
readonly code: SessionStoreErrorCode;
}
Every member is a registered ReasonCode (§12) with a shipped core.reason.* string, so a
client renders code through the string table like any other rejection and never reads
message. That is what makes these safe to surface: a demo showing "could not be saved
locally" is rendering storage_failure, not string-matching English (09 §3).
An adapter throwing is storage_failure, with exactly one classified exception. The store
catches whatever a host implementation raises and re-raises storage_failure, so a Postgres
timeout and a localStorage quota error stay indistinguishable to a client — a client can do
nothing different about either, and a host's own exception type leaking through the store would
put an unbounded vocabulary on the other side of the boundary.
The exception is concurrent_modification, and it exists because §7's two lock domains stop
at the process edge. Per-sessionId locking orders operations within one store instance; a
host running several instances over one database has sessions that no lock here serializes, and
that host is the only party positioned to detect the overwrite. It signals one by branding
an exception rather than by raising a type of its own:
const SESSION_PERSISTENCE_CONFLICT = "SessionPersistenceConflict";
interface SessionPersistenceConflict extends Error {
readonly name: typeof SESSION_PERSISTENCE_CONFLICT;
}
The brand is a string on name, deliberately, and not an instanceof check — a host may
resolve a duplicated copy of this package, across which class identity does not survive.
Two rules bound the carve-out, and they are what keep the paragraph above intact rather than
merely qualified. The vocabulary stays closed: one brand, one code, and every other adapter
exception still maps to storage_failure with no path by which a host adds a third outcome.
And a classified failure must be one the caller can act on differently —
concurrent_modification earns its place because "re-read the session and retry" is a real and
different response, which is exactly what a timeout and a quota error do not have. A later code
needs both arguments made here; a brand invented downstream is not a contract.
A failed write leaves no trace in the store's cache, whatever the failure. A record a command creates (a session from
createSession,loadGameorbranchSession, or a save fromsaveGame) is published to the cache only after its write resolves. A save that persistence refused is never listed or loadable, from this instance or any other. The paragraph below is the same rule for a record that already exists.No read sees a write that has not committed. An operation that changes an existing session record (an accepted
submitAction, or aresumeSessionthat adopts) builds the next record as a separate value and writes that. It applies the value to the cached record only after the write resolves. While the write is pending,getView,getScene,serializeand the other reads that take no session lock still return the last committed record. So does the source snapshotbranchSessiontakes on the source's lock (§7.4). Restoring on failure is not enough. A read that ran while the write was pending would already have returned a state that persistence then refused, and a branch taken in that window would have copied it.A rejected write leaves the store at its persistence. This failure is actionable — the shipped
core.reason.concurrent_modificationstring tells a player the session changed elsewhere and to refresh — so a refused value must never reach the cache. With the rule above it never does, and the next read serves the last committed state.storage_failurekeeps the cached record, because the cache was right and only the write failed.On
concurrent_modificationthe store also evicts. The conflict means another writer committed, so the cached record is itself stale, and only a fresh read from persistence lets the retry build on the winner's write. A command already queued on the session lock holds the same record, still at the stalerevision, so the adapter refuses its write too.
7.3 The Campaign Catalog
listCampaigns is the only operation a client calls before a session exists, and it was the
one operation written as though it could not fail and could not wait. Two consequences followed,
and this section settles both.
listCampaigns(profileId?: string): Promise<CampaignCatalog>;
It is asynchronous because a store is not required to hold its campaigns in memory. The
synchronous signature made that a silent requirement: a fetch-backed SessionStore could not
satisfy it at all, so the first host to try prefetched every summary at composition time and
closed over them. That works and is not what the contract said — it said a store must already be
a registry before it is a store. Every other query on this interface is already Promise-
returning; this one was the outlier, not the norm.
The result carries the strings, so a catalog is renderable without a session. titleKey
stays a LocKey — it is not resolved into the summary — and CampaignCatalog.strings is the
table that resolves it. This is getStrings (§7) applied one layer earlier, for the same reason
and with the same property: no locale is baked into a returned DTO, and a client never
string-matches English.
strings carries exactly the keys the summaries carry, and nothing else. It is not the
registry's table. That bound is load-bearing rather than an optimization: the full table holds
every node's authored prose, so shipping it to a visitor choosing a campaign would hand out the
whole of every story before a session began — a spoiler leak dressed as a convenience, and
precisely the reach-past-the-boundary this section exists to close. A catalog that grows a new
LocKey grows its table by that key.
The catalog is titles and progress. It is never content. No node, no variable schema, no
choice, no achievement definition, no Campaign.content in any form. A client that needs those
starts a session.
Order is the registry's own iteration order, which registry assembly fixes when it freezes (§10.1), and this section does not impose a sort on top of it. Two calls against one registry return the same order; a client that wants a different one applies its own.
Progress, and the profileId that gates it
progress is present on a summary iff listCampaigns was given a profileId, and absent
otherwise — an anonymous catalog reports nothing, matching §7.1's anonymous by default. When
present:
discoveredis the number of distinctTerminalRecord.terminalIds the profile holds for thatcampaignId(§7.1).totalis whatKind.terminalCount(§3.2) reports for that campaign. A kind that omitsterminalCountyields noprogressobject at all for its campaigns, even with aprofileIdsupplied — adiscoveredwith 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.
savedAtis new onStoredSaveRecord, and the sort is why.savedAtSeqwas 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.StoredSessionRecordalready carriescreatedAt/updatedAton the same reasoning (§7.2); this closes an asymmetry rather than opening a new axis.It is
Clock-stamped (06 §5.4), neverDate.now, and it lives on the record rather than inGameState— 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
savedAtfor existing rows. There is no correct value to invent for a save taken before the field existed; backfill them to the epoch so they sort last, which is honest about the fact that their real time was never recorded.
A summary is metadata, never content. No blob, no SaveEnvelope, no GameState, no
kindState, and no campaign title. Resolving campaignId to a display name is
listCampaigns (§7.3), which already carries the LocKey and the table that resolves it; a
second string table here would be the projection boundary crossed twice for one string.
Degradation matches §7.1's. An adapter that throws surfaces storage_failure like any
other (§7.2). A profile that does not exist is not an error — it lists nothing.
Deleting
deleteSave(profileId: string, saveId: string, expectedSavedAt: string): Promise<void>;
A wrong-profile delete is indistinguishable from a missing save. Both raise
unknown_save, and this is deliberate rather than lazy: a distinct "not yours" code would
confirm that a saveId exists to a caller holding no claim on it, which is an existence oracle
over other players' records. One code, no oracle, and a host that wants to log the difference
can — it has both values.
expectedSavedAt is a precondition, not a convenience. The delete removes saveId only
while its stored savedAt still equals the value the caller observed; otherwise it removes
nothing and raises concurrent_modification. The failure it exists for is the one this unit
names: a player opens a save list, a second writer replaces a record, and the delete authorized
against what the player saw would otherwise erase a record they never looked at. The stamp is
returned by both saveGame (SaveHandle.savedAt) and listSaves, so every caller already holds
one and no extra round-trip is needed to obtain it.
The compare and the delete are one step, not two. SaveRecordStore.delete takes the
expected stamp so an adapter can express it as a conditional statement; the store's own
per-saveId lock domain (§7) covers the single-instance case. A host running several instances
over one database is again the only party positioned to detect the overwrite, and signals it the
way §7.2 already established — by branding SessionPersistenceConflict, which the store maps to
concurrent_modification. No new brand and no new classified failure: this reuses the one
carve-out §7.2 opened, and the argument that earned it holds here unchanged, because "re-read
the list and try again" is exactly the different, actionable response.
A refused delete leaves every record untouched — not merely the addressed one. An implementation that removes the record and then discovers the mismatch has already failed the criterion; the condition is evaluated before anything is removed.
Branching
branchSession(sessionId: string, atActionCount: number): Promise<SessionHandle>;
atActionCount is the number of logged actions the branch retains, so 0 branches at
creation and actionLog.length branches at the present. Valid range is [0, actionLog.length]
inclusive; anything outside it raises invalid_fork_point and writes nothing. A count is used
rather than a LoggedAction.seq because the two coincide only while a log is gap-free, and a
count states the intended meaning — how much of this game — without depending on that.
It counts log entries of every type (§2): a content entry is part of how much of this game
there is, so a fork may fall either side of an adoption, and one taken before it starts on the
old epoch.
There is a working reference implementation, and it is not quite this. Issue #266 records Adventures forking by replaying a stored log to an
atSeqand writing aStoredSessionRecordstraight throughpersistence.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 withrandomUUID()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: nbecomesatActionCount: n + 1for a dense log, which every log this engine writes is. Stated because the two signatures accept the same type and differ by one — the failure mode is a fork one action short, which replays cleanly and is wrong.
The branch is replayed, not copied. The store creates a game from the source's
{ gameId, seed, campaignId } and its recorded initialization.kindProfileData (§7.1, §7.2) at
its starting version (§2) and replays the retained prefix
through the ordinary path — an action through submitAction, a content entry through
adoptContent, which must adopt. Then the branch reaches its own adoption point (§7). Before it
mints the branch's session id, the store resolves the starting version and every retained
content entry's to through engine.content.resolve, requiring an exact version match, so a
withdrawn epoch is unknown_campaign rather than a late invalid_state
(90-decisions.md, 2026-10-09, S134).
Starting from the source's current campaignVersion instead would replay a prefix against
content it was never played on. Truncating the source's blob would leave kindState at the present while the log
claimed the fork point — a state no sequence of actions produces, and one that would serialize
to something replay could never reach.
The branch retains the source's
gameIdand mints only a newsessionId. This is the load-bearing decision of the operation.
gameIdis an envelope field (§2) and therefore inserialize()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 withgameIdnormalized out, which is precisely the normalization 06 §5.1 records theIdSourceport 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:
gameIdidentifies 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
sessionIdcomes fromRecordIdSource.newSessionId()(06 §5.7), notIdSource. That is the port whose values never enterGameState, which is exactly what a branch needs a fresh one of. The unit's own wording saysIdSource; it means this, and the distinction matters because minting fromIdSourceis what would have produced the newgameIdthis decision rejects.
The source session and every save it made are untouched — not re-stamped, not re-serialized, not re-locked beyond the read. That read waits its turn on the source's session lock, so it sees the last committed record and never a write still in flight (§7.2). A branch is a new record; it is never a mutation of an old one.
profileId and initialization are inherited from the source record, because a branch is
the same player's game started from the same input. An anonymous session branches to an
anonymous one.
A source whose initialization is unknown branches only where no profile could have seeded
it. A record written before initialization existed carries no wrapper (§7.2). If it has no
profileId, or its kind declares no profileData, initialState received no profile data, and
the branch replays with {}. Otherwise the input is lost: the store raises invalid_state
before it mints an id or writes anything. Seeding from the profile as it is now, or from
nothing, would produce a branch that silently differs from its prefix.
A session that is not replay-compatible cannot be branched. A StoredSessionRecord with
replayCompatible: false has passed through a migrated load (§10.2), and its log is no longer
guaranteed to regenerate its state — which is the entire mechanism a branch depends on. It
raises invalid_state and writes nothing. A source whose log carries a migration entry (§2) is
refused the same way; the entry implies the flag, and makes the refusal readable from the state
where it exists. Failing here is the sticky-forward rule doing its
job: the alternative is a branch that silently diverges from the game it claims to continue.
A retained action the kind now refuses is refused the same way. A kind narrows what it
accepts without a kindVersion bump when no valid save changes (§3's declaration; 90-decisions,
2026-11-01), so a log written by an older build can still hold an entry carrying an undeclared
key, or — for simulation — naming an id the kind never issued (10 §4, unknown_target).
Re-running it rejects that entry; the store raises invalid_state and writes nothing. A
fork point before the entry retains none of it, and branches.
Reproducing a stored session from its log
Reconstructing a session's blob from { seed, actionLog } requires IdSource.newGameId
pinned to the original gameId. This is stated because it is the one non-obvious step and
because it has been rediscovered once already: gameId is an input to the envelope, not a
derived value, so a reconstruction run under a fresh IdSource differs from the original in
that field and in nothing else — a difference small enough to be mistaken for a normalization
problem and large enough to fail a byte comparison. 07 §5 named the port for exactly this, and
the runner's counting IdSource is the same mechanism applied to fixtures.
The obligation is checkable in both directions, and both belong in the proof: reconstruction
under a pinned newGameId is byte-identical to the stored blob, and reconstruction under any
other id is observably different. A test that asserts only the first cannot tell a correct
pinning from an engine that ignores gameId altogether.
Capturing a session
interface SessionLayer extends SessionStore { // 06 §4 — host-only, never handed to a client
forget(sessionId: string): Promise<void>;
captureSession(sessionId: string, name: string): Promise<ReplayFixture>;
}
Capture is a whole-log branch that writes nothing and returns its input. It holds the session
on its own queue, as branchSession holds its source. It admits the session through the same
gates and replays the whole log from the starting epoch, exactly as a branch at
atActionCount = actionLog.length would. Then it returns the input to that replay instead of
writing a session from it:
| Fixture field | Taken from |
|---|---|
name | the caller's name, unchanged |
config | { campaignId, seed }, plus kindProfileData from the record's initialization where it is present (§7.2). Nothing else: no audience, no profileId, no sessionId |
campaignVersion | the starting epoch, as branchSession resolves it (16 §5.6) |
capturedUnder | the running ENGINE_VERSION |
submissions | one per log entry, in order: an action entry becomes { actionId, params } copied verbatim, a content entry becomes an AdoptionSubmission to its to |
Every refusal is branchSession's, for the same reason, and none is new. The replay and its gates
are one implementation shared by both operations, not two that agree. invalid_fork_point cannot
arise, since capture takes no fork point. A fault-triggered capture is still a capture of the log,
so the submission that faulted is not in it (08 §5).
name must not identify anyone. The store passes it through and cannot check it. Deriving it
from the session id, the profile or the account would undo §3.1 of 08 through the one field no
kind judges. That rule is the host's to keep (08 §8).
Authorization is host-owned, and SessionStore stays caller-agnostic
profileId on these operations is a scoping key, not a credential. The store treats it as
an assertion already established and never as one it verifies: it has no notion of a caller, no
session token, no tenancy, and no way to distinguish a host passing a profile it authenticated
from one passing a profile it read off a query string. Authenticating a caller and proving the
profileId is theirs is the host's job, entirely, on both existing hosts and any future one.
This is 06 §2's rule read the only way it can be. Authorization cannot change serialize()
output, so it is outside the determinism boundary and therefore outside the engine — and a
SessionStore that grew an identity model would be a store that could refuse a replay, which is
the one thing it must never be able to do.
The deferred trigger, named so it is not rediscovered as a gap. This decision holds while every host owns its own front door. It reopens when the hosting/NEaaS layer (
SubZeroDev.Platform) serves multiple tenants from one store instance, because at that point "the host authenticated this caller" stops being one statement and becomes a claim the store is the only party positioned to scope. That is a contract amendment when it arrives, not a defect now; it is issue #281 in90-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 by | Code | When | Retryable | Caller does |
|---|---|---|---|---|
listSaves | storage_failure | the adapter threw | Yes | retry; the list is not authoritative state |
deleteSave | unknown_save | no such saveId, or it belongs to another profile | No | drop it from the list; it is not the caller's |
deleteSave | concurrent_modification | stored savedAt ≠ expectedSavedAt | Yes, after re-reading | re-run listSaves and re-confirm before deleting |
deleteSave | storage_failure | the adapter threw | Yes | retry; nothing was removed |
branchSession | unknown_session | no such sessionId | No | the session is gone |
branchSession | invalid_fork_point | atActionCount outside [0, actionLog.length] | No | new — correct the count |
branchSession | invalid_state | the source is replayCompatible: false, or its log carries a migration entry | No | branching this lineage is impossible, not delayed |
branchSession | invalid_state | the source record predates initialization, has a profileId, and its kind declares profileData | No | the creation input is lost; branching this lineage is impossible |
branchSession | invalid_state | an action in the retained prefix is one the kind now refuses — logged before the kind declared its params (§3), it carries a key the kind no longer accepts, or it names an id the kind never issued (10 §4) | No | the log cannot regenerate its state under this build; branch before that entry |
branchSession | invalid_state | a content entry in the retained prefix no longer adopts | No | the kind's compatibility rule changed under a recorded adoption — 07 §6's divergence, one layer down |
branchSession | unknown_campaign | an epoch the retained prefix plays on — its starting version or one it adopted — no longer resolves | No | the content was withdrawn (07 §6's unrunnable, one layer down) |
captureSession | unknown_session | no such sessionId | No | the session is gone; nothing to capture |
captureSession | invalid_state | the session is replayCompatible: false, or its log carries a migration entry | No | this session cannot become a fixture |
captureSession | invalid_state | the record predates initialization, has a profileId, and its kind declares profileData | No | the creation input is lost; this session cannot become a fixture |
captureSession | invalid_state | a logged action or content entry is one the kind now refuses | No | the log cannot regenerate its state under this build; capture under the build that wrote it, or not at all |
captureSession | unknown_campaign | an epoch the log plays on no longer resolves | No | the content was withdrawn; the fixture would be unrunnable (07 §6) |
invalid_fork_point joins SessionStoreErrorCode and needs its core.reason.invalid_fork_point
string registered with the rest (§12); registry construction rejects an override of it like any
other protected core key.
Invariants
Each is written to become an assertion. The session store maintains all of them; none is
delegated to an adapter, which is the point of drawing the seam at SessionPersistence
(06 §5.2).
- L1.
listSaves(p)returns everyStoredSaveRecordwhoseprofileId === p, and no other record. Enforced by code. - L2.
listSavesoutput is sorted bysavedAtdescending, thensaveIdascending, and the order is total — no two distinct records compare equal. Enforced by code. - L3. A
SaveSummarycarries no field that is not onStoredSaveRecord, and neverblob. Enforced by the type; a widening is a contract amendment. - D1.
deleteSaveremoves exactly one record on success and exactly zero on any failure. Enforced by code. - D2. A
deleteSavewhoseexpectedSavedAtdoes not match the stored value removes nothing and raisesconcurrent_modification. Enforced by code, and by the persistence conformance suite deterministically reproducing the interleaving. - D3. A
deleteSavefor another profile'ssaveIdis indistinguishable in its result from one for asaveIdthat does not exist. Enforced by code. - B1. For a session
Sand anynin[0, |S.actionLog|], the branch produced bybranchSession(S, n)with no content offer at its adoption point serializes byte-identically toSreplayed tonfrom its starting epoch —gameIdincluded, because it is retained. Atn = |S.actionLog|it equalsserialize(S)exactly, which is the cheapest form of the assertion and the one a golden file should hold. An offer that adopts appends exactly one content entry to that serialization and changes nothing before it. Enforced by code. - B2.
branchSessionperforms 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
sessionIdis distinct from every other session's; itsgameIdequals its source's. Enforced by code. - B4. A branch's result depends on its source's recorded
initializationand never on the source's profile as it is now. Changing that profile aftercreateSession, or mutating the object theProfileStorereturned, leaves B1 holding. Enforced by code. - F1.
captureSessionwrites nothing: no record, no cache entry, noStateChange, on success or failure. Enforced by code. - F2. For a session
Sthat captures, the fixture's submissions correspond one-to-one, in order, withS.actionLog, and running it through 07's runner rejects none of them. The capture replay is the replaybranchSession(S, |S.actionLog|)performs, so B1 is what makes the fixture reproduceS. Enforced by code and by a capture-then-run round-trip test per kind. - F3. A fixture carries no
profileId,sessionId,audience, timestamp, or host metadata;confighas no key outside{ campaignId, seed, kindProfileData }. Enforced by code; a widening is a contract amendment to 08 §3.1. - F4. Two captures of the same unchanged session under the same build with the same
namereturn equal fixtures. Enforced by code — capture reads nothing a clock orIdSourcesupplies. - 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.
SessionStorenever verifies aprofileId. 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.
| Stream | Derived for | Why it is its own stream |
|---|---|---|
action:${seq} | submitAction (§4) | The play spine — one stream per resolution |
system:start:0 | createGame'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
viewstream 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. ButKindContextis one type and a read path must supply anrnghandle 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 oneStreamIdencoding 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.seqis normative, and it is not the action seq. It is the agent's own draw counter, stored on that agent inkindStateand 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
tickvariant is for world-level draws in a kind whose turn advances simulated time — guest spawning, incident rolls, weather. Keying them bytickrather than byseqis 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.systemhere names the drawing system, not aStreamIdvariant, so two systems drawing on the same tick stay independent.
weightedPickconstrains 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
aiand notagent. "Agent" was doing two incompatible jobs across these specs: an AI player here, and a simulated entity inStreamId({ kind: "agent"; agentId }, §8) and throughout12-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 andagentnow 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 byEngine.scene(state). §6 declares that field to be the §9 projection, so this is the same obligation rather than a parallel one.
Both are declared in src/engine/src/core/kernel/engine.ts.
What a caller may rely on: mutating anything reachable from a returned view — nested records and
arrays, to any depth — leaves GameState, every later view() or scene(), serialize()
output, and the action log unchanged. What a caller may not rely on is object identity. Two
projections of one state are equal and are never the same object, so a client must not use a view
as a cache key or compare views by reference.
Immutable primitives are not copied, and need not be. Strings, numbers, booleans, null and
undefined are values; a copy of one is indistinguishable from it. The rule governs reachable
containers only. readonly in a view type is a compile-time annotation this rule does not depend
on and is not enforced by: WorldGraphView marks its arrays readonly and StoryGraphView does
not, and both are equally protected, because neither annotation is what protects them.
The one obligation this places on a kind: a project result must be plain, structurally
cloneable data — no functions, no class instance whose prototype the view depends on, nothing
that cannot survive the boundary. A kind that returns such a value fails at its first view()
rather than silently handing out a live reference, which is the failure mode worth having.
Engine.availableActions is outside this rule and needs nothing from it: AvailableAction (§6)
is a flat record of primitives and carries no projection.
Why the kernel and not the kind. A kind is the wrong owner for an invariant no kind can be checked against. All three alias state while projecting today, and each is locally reasonable — a projection legitimately reuses the value it is narrowing. Placing the rule on kinds turns it into an instruction every future kind must re-obey with nothing to catch a lapse, and this repository has already recorded that exact shape failing twice (
90-decisions.md— the emitted → registered gap, and the end-of-week stub register: "a rule with no gate, checked only when someone compares the two sets by hand"). At the kernel it holds by construction, for kinds that do not exist yet.The cost is one copy per read, and it is affordable precisely because a projection is narrow by construction — this section and each kind's own projection section exist to keep it so.
10. Content, Saves, Migration
10.1 Content Registry
interface ContentRegistry {
readonly campaigns: ReadonlyMap<string, Campaign>;
readonly strings: ReadonlyMap<LocKey, string>; // built form — see the authoring boundary below
readonly resolution?: ResolutionId; // the pack set this was folded from (11 §4, §6)
/** Per campaign id, what that campaign authored — the input to its epoch identity (§4, C28).
* Set by every engine builder; `ContentArchive.publish` refuses a registry without it. */
readonly provenance?: ReadonlyMap<string, CampaignProvenance>;
}
interface CampaignProvenance {
/** The campaign's own strings plus those of every module it composes, transitively — never
* the core catalog or a kind's `reasonMessages`, even where an authored key equals one. */
readonly strings: ReadonlyMap<LocKey, string>;
/** The attachments applied to it, in application order (§10.4). */
readonly attachments: readonly CampaignAttachment[];
}
interface Campaign {
id: string;
kindId: KindId;
version: string;
titleKey: LocKey;
content: unknown; // kind-specific — e.g. StoryGraphCampaign (03 §1)
/** Modules this campaign composes in at registry build (§10.4). Absent means none. In the
* frozen registry the list stays as provenance only; `content` is already composed. */
includes?: readonly CampaignInclude[];
/**
* Migrates a `kindState` forward when *this campaign's own* content ids or shape changed
* between `fromVersion` and this `version` (§10.2) — a renamed node or achievement id.
* Optional, for the same reason `Kind.migrateState` (§3) is: most version bumps rename
* nothing a save references. Runs at the save-load boundary, after any
* `Kind.migrateState`, and at adoption (`Engine.adoptContent`, §4) on the *target* epoch's
* campaign before the kind judges — never during `advance`. The rule for what it may do is
* the same at both (§10.2).
*/
migrateState?(kindState: unknown, fromVersion: string): CommandResult<unknown>;
}
resolutionis optional, and the optionality is the contract. It is present only on a registryresolvePacksfolded from an ordered pack set (11 §4) —buildContentRegistryknows 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 changecampaignVersionfor 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.
provenanceis ownership by origin, not by prefix. The mergedstringscannot say who wrote a key once an authored key equals an engine one, so the builders record it as they merge:buildContentRegistry,buildValidatedContentRegistryandbuildValidatedPackRegistryall populate it. For a pack registry the per-campaign table is the whole fold minus engine messages, since a pack's strings are not attributed to one campaign. It is optional in the type only so that a hand-built registry — a test, a fixture — still type-checks; such a registry can be played but not published (content_provenance_missing, §12), because without it the archive could only hash the merged table, which is the full-table identity C28 rejects.
Two
migrateStatefunctions, two axes.Kind.migrateState(§3) is typedCommandResult<KState>because a kind knows its own state type; this one is typedCommandResult<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 repeatid,kindId,version, ortitleKey, which live onCampaignhere. Authored inline strings are lifted intoregistry.stringsat build time (the authoring boundary below), socontentcarries no per-campaign string table at runtime. Same anti-drift rule askindState(§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
unknowninto 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/serializationVersionmismatch 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.engineVersionmismatch never gates a load by itself — recorded for provenance only, per this section's own reasoning that it changes independently of the others.kindVersion/campaignVersionmismatch is the actual migration:Kind.migrateState(§3) runs first for a kind-state shape change, thenCampaign.migrateStatefor 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).checksumcovers{ 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.kindIdagainst the outerkindId, and both outer ids and the outercampaignVersionagainst the embedded (and therefore checksummed)GameState's own — a differing version isinvalid_state, since the load resolves and migrates from it (90-decisions.md, 2026-10-09, S134).stateis in scope because it is the thing being protected;replayCompatibleis in scope because nothing else guards it, and flipping a migrated save'sfalseback totruein the stored blob would otherwise silently defeat the sticky-forward rule below.90-decisions.mdrecords why this is the accepted scope rather than a gap.- The kind judges the result. After both migrations,
Kind.validateState(§3) is asked about thekindStatethis version would run — a migrated one in its migrated shape. A refusal fails the load withinvalid_statewhen nothing was migrated (the save itself is corrupt) andmigration_failedwhen something was (the migration produced a state its own kind cannot run). - A save whose epoch the archive still holds loads without migration. When
engine.contentresolves the save's(campaignId, campaignVersion)and itskindVersionequals the registered kind's, the save loads on that epoch exactly as it was made —replayCompatiblekept — and moves forward atloadGame's adoption point (§7) by a logged adoption instead. The campaign-version mismatch the bullet above migrates is then a mismatch with the default epoch only, and is not one. Migration remains the path for an epoch the archive no longer holds and for every kind-shape change (16 §7). - A successful migration sets
replayCompatible: false, sticky forward — once a lineage has passed through a migrated load, it never becomes replay-compatible again, even across further saves that need no further migration. - A migration that changes
campaignVersionover an epoch-bearing log logs it. When the log already carries an epoch entry, the step that restamps the version also appendsLoggedMigration { seq: actionLog.length, system: "migration", from, to }—fromthe state'scampaignVersion,tothe registry campaign's, appended only after the kind accepts the migrated state (90-decisions.md, 2026-10-09, S134); the state is alreadyformatVersion: 2and stays so (§2). Without the entry, such a save would migrate into a state C26 refuses — its lasttonaming the epoch it left (16-content-epochs.md§3.5; red-team F1). A migration over a log with no epoch entry appends nothing and keepsformatVersion: 1, so a host that never adopts writes the saves it wrote before content epochs. A migration that changes onlykindVersionrestamps no version and appends nothing. A migration entry is never replayed:branchSessionrefuses a log carrying one (§7.4), capture records none, and §14's runner fails a fixture that carries one. - A migration is idempotent against its own output. Migrating a save, saving it, and
migrating again — which is what happens whenever a player loads, plays nothing, and saves,
or whenever a fixture is regenerated — must reach a canonically identical
kindState. The first pass restampscampaignVersionto the campaign's own (resolveSaveEnvelope), so the second pass finds no mismatch, runs no migration and appends no second migration entry; that is why it holds, and stating the property rather than the mechanism is what makes it testable against a migration that mutates in place, accumulates, or appends.replayCompatiblestaysfalsethrough both, which is the sticky-forward rule above doing its work.
What each axis may do to state, stated as a rule rather than per kind. Kind.migrateState
may change the shape of kindState — add a field with a default, drop one, retype one. A
campaign migration may only re-address published content ids the state references, and drop
or default the references that no longer resolve. Neither may invent play: a migration that
awarded money, advanced a week, or unlocked an achievement would make the same save load
differently depending on which version it passed through, which is the class of defect
replayCompatible: false exists to record and not to license. A campaign migration that
finds a reference it can neither remap nor drop fails with migration_failed rather than
guessing — this is architecture §8's "map old ids forward or fail loudly", said from the
implementation side.
A campaign migration accepts every predecessor it can reach, not only those it
re-addresses. Adoption runs the target epoch's migration from the session's own version
(16-content-epochs.md §5.4), and so does a load whose epoch is no
longer held. An extension that keeps its predecessor's migration is therefore called with
that predecessor's version, which has nothing to rename: for such a version the migration
returns the state with no id moved. It fails only for a version it cannot reach. A migration
written for the version before last would otherwise refuse migration_failed on every
adoption onto the extension, and every live session would stay pinned on the old epoch
(90-decisions.md, 2026-10-15).
Migration functions are engine-or-content-owned, never a host-supplied port: a port may
supply anything that cannot change serialize() output
(06-extensibility.md §6), and remapping old ids is definitionally
a change to it. Proven in core/persistence/envelope.test.ts against a synthetic
kind/campaign (plans/38-save-migration-programme.md). The one real campaign migration is the
adventure campaigns' 1.0.0 → 2.0.0, proven in campaigns/adventure-builder.test.ts.
saveFormatVersionvsGameState.formatVersion— different things.saveFormatVersionversions this wrapper;GameState.formatVersion(§2) versions the envelope inside it. They are separate becauseEngine.serialize/deserializeround-trip a bareGameStatewith 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 aSaveEnvelopechecks both.
The migration hazard, made concrete (architecture §8). A save records the
campaignVersion it ran. Loading it against a different published version runs
migration, which must map old ids forward (a story-graph node id that was renamed) or
fail loudly — never strand the player on content that no longer exists. A migrated
save is replayCompatible: false: its action log can no longer be guaranteed to
regenerate its history, because the rules changed.
10.3 Why Not Event Sourcing
The design carries an action log, deterministic replay, and byte-identical state — the ingredients of event sourcing. It stops deliberately short of adopting it as the persistence model.
Pure event sourcing makes current state a derived projection: state = replay(log),
and you persist the log, not the state. That collides head-on with the migration rule
above. A migrated save is not replay-compatible — its log can no longer regenerate
its state across a rule change — so under pure event sourcing a migrated save would be
unloadable. Instead the core persists current state (the envelope) and keeps
the log: you get event sourcing's benefits where they pay off — the determinism harness
(§14) and bug reproduction replay from { seed, actionLog } within one version — without
its cost, which is loads that break the moment the rules move. This hybrid is a choice,
not a gap.
10.4 Campaign Composition
Content crosses a campaign boundary by composition at registry build, never by traversal
(90-decisions.md, 2026-10-07, #293/#292). A host campaign includes another campaign as a
module; registry assembly merges the module's content into the host's before validation
runs, so at runtime there is one campaign, one session and one GameState. Nothing in the
envelope, the save format or any host changes.
/** A pin on a campaign's *authored* version — never a resolution id (11 §6). */
interface CampaignRef { id: string; version: string; }
interface CampaignInclude {
/** The prefix the module's ids take in the host (§17, *Composed ids*). An authored id:
* ASCII `[a-z0-9_-]`, no `:`, unique among the host's includes and attachments. */
alias: string;
ref: CampaignRef;
/** Kind-specific — entry, exit and variable mappings (e.g. `StoryGraphIncludeBinding`,
* 03 §1.1). Opaque to the core, like `Campaign.content`. */
binding: unknown;
}
/** An include a content pack adds to a campaign it does not own (11 §3a). */
interface CampaignAttachment {
hostCampaignId: string;
include: CampaignInclude;
/** Kind-specific — where in the host the module is reached from (e.g.
* `StoryGraphAttachment`, 03 §1.1). */
payload: unknown;
}
/** What `Kind.composeContent` (§3) receives per module: the include as declared, and the
* module campaign already composed — its own includes and attachments merged in. */
interface ComposedModule {
include: CampaignInclude;
campaign: Campaign;
}
interface ComposedAttachment {
payload: unknown;
module: ComposedModule;
}
The split. The core owns the envelope; the kind owns the merge.
| Step | Owner | Fails with |
|---|---|---|
The include's ref.id names a campaign in the same registry input | core | include_missing |
ref.version equals that campaign's authored version | core | include_version_mismatch |
The module's kindId equals the host's | core | include_kind_mismatch |
| No campaign includes itself, directly or transitively | core | include_cycle |
alias is well-formed and unique among the host's includes and attachments | core | invalid_identifier, include_alias_collision |
An attachment's hostCampaignId names a campaign in the registry input | core | attachment_host_missing |
The host's kind implements composeContent | core | compose_unsupported |
| Bindings, entries, exits and attachment points are meaningful for this kind | kind | the kind's own codes (03 §8.3) |
The order is fixed, and determinism depends on it. Campaigns compose in registry-input order; a host's includes in declared order, then its attachments in pack order and, within one pack, declaration order. Composition is depth-first: a module is composed — its own includes and its own attachments — before any host that includes it, and each campaign is composed once and reused. A module is therefore always included in its composed form, so a pack attachment on a module reaches every host that includes it. There is no depth limit; the cycle check is the only bound.
The pin is on the authored version. resolvePacks stamps every folded campaign's version
with the resolution id (11 §4), so a pin checked against the stamped value could never match.
The fold keeps each campaign's authored version beside it (11 §3a), and the pin is checked
against that. In the unpacked path (buildValidatedContentRegistry) the two are the same value.
Composition precedes validation. Every Tier 1 and Tier 2 check (§11) runs on the composed
campaign — the content play will actually see. A module stays an ordinary campaign: it is also
validated, and playable, standalone. The frozen registry holds the composed content; the host's
includes stay on it as provenance, and nothing reads them again.
Composed ids are published ids. A module node x included under alias a is a::x in
kindState, in saves and in replay fixtures. Renaming an alias is therefore a rename under C18 —
a Campaign.migrateState, never an edit — and so is re-pinning a module whose own ids moved.
Why not compose at runtime. A target chosen at runtime needs call/return frames in
kindState, which is a state-shape change and a save migration. The module interface fixed here — entry, exits, inputs, outputs — is exactly what a frame would call, so that remains the extension if it is ever needed (90-decisions.md, 2026-10-07).
11. Tiered Validation
Every campaign is validated before the registry is frozen. The core runs the
tiers; the kind supplies the checks via validateCampaign.
interface ValidationResult {
ok: boolean; // false iff any Tier-1 error
errors: ValidationError[]; // Tier 1 — hard fail
warnings: ValidationWarning[]; // Tier 2 — load but flag
}
interface ValidationError {
code: ReasonCode;
messageKey: LocKey;
path?: string; // where in the campaign
details?: Readonly<Record<string, string | number | boolean>>;
}
interface ValidationWarning {
code: ReasonCode;
messageKey: LocKey;
path?: string;
details?: Readonly<Record<string, string | number | boolean>>; // e.g. restore's (§4)
}
-
Tier 1 — load-time, hard fail: referential integrity, schema conformance, declared variables, path validity, duplicate ids, missing string keys. (Story-graph's Tier 1 is 03 §11.)
-
Tier 2 — load-time, warning: unreachable content, unexpected cycles, and
no_reachable_choice— a campaign that settles straight to an ending with no choice node reachable from the start. It loads and plays (§3,InitialStateResult.statusreports"ended"immediately); the warning tells an author their campaign is non-interactive without forbidding a deliberate vignette or a single-scene test fixture. -
Tier 3 — simulation-time: unwinnable campaigns, dead-end states — found by running, not reading. Not part of load, and not part of §14 either: the determinism harness compares a build against itself and cannot answer whether an ending is reachable. Tier 3 is an author-facing check run out of band —
npm run validate-campaignin 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
boundedwhenever any cap was reached anywhere. A bounded result means "not proven", never "passed." A caller that reads the two as the same thing gets a guarantee the checker never offered: it declines to credit an ending found exactly at a cap, precisely so it never claims to have explored more than it did.
Why tiered: "the engine validates AI-authored content" (architecture §9) is only a safety property once you say what validation is and what is decidable when. AI output is data; all data goes through the same tiers, whatever produced it.
Which string table validation checks against
Two of this section's requirements look like they need each other's output. Every campaign
must be validated before the registry is frozen (above), and §12 requires that a registered
reason code with no localized message fails validation — but the final merged string table on
ContentRegistry is a product of registry assembly (§10.1), which runs after validation
clears. Read literally, neither could go first.
They are not in conflict, because "a LocKey resolves" is scoped per campaign, not against
the merged table:
- A campaign's own
LocKeys —titleKey, and everythingKind.validateCampaignreaches insidecontent— are checked against that campaign's built string table, theBuiltCampaign.stringsthe authoring builder lifted out of its source (§10.1). That table is complete for the campaign by construction: a key it authored but did not lift cannot exist. Merging adds other campaigns' keys, which this campaign has no business referencing anyway, so the merged table would not make the check stricter — only later. - Reason-code messages are checked against the declaring kind's own message table,
once per kind rather than once per campaign, and against the core's shipped
core.reason.*set for the base codes. A kind therefore fails registry construction for a gap in its own vocabulary before its messages ever reach the merge.
So the ordering is: validate each campaign against its own strings and each used kind against
its own messages; only then assemble, merging core, kind and campaign strings and freezing
both maps. buildValidatedContentRegistry
(src/engine/src/core/validation/tiered.ts) is the sanctioned entry point that runs them in
that order, and it threads each used kind's messages into assembly so the frozen table
carries them.
For a pack set rather than a single campaign batch, buildValidatedPackRegistry(packs: readonly ContentPack[], kinds: KindRegistry): CommandResult<ContentRegistry> (same file) is
the sanctioned entry point (11 §3, W76): it runs resolvePacks (§7 below) to fold the set,
then this same validate-and-assemble sequence against the folded campaigns and string table,
and reattaches the fold's resolution id — neither stage alone can produce a validated,
resolution-stamped registry. It is exported from the package root alongside
buildValidatedContentRegistry.
Composition runs first, in both entry points (§10.4). Each entry point composes every
campaign that declares an include or receives an attachment, and only then validates — so the
ordering above becomes compose, validate, assemble. A composition failure is a Tier 1 error
like any other: it is reported in the same errors list, and the registry is never built. A
composed host's own string table, for the per-campaign check above, is its built strings
united with those of every module it composed, transitively. A module's LocKeys stay
unprefixed (03 §1.1), so the host's content references them by their own keys. This is not the
silent cross-campaign dependency the note below rules out: the include declares it and pins its
version.
This is a clarification of scope, not a weakening. Nothing here permits an unresolved key into a frozen registry. The merged table is a superset of every per-campaign table plus the core's and the kinds' own, so a key that resolves in the narrower table resolves in the wider one; checking early is strictly earlier, not looser. What it does rule out is a campaign silently depending on a different campaign's authored string — which the merged table would have let through, and which is a real drift surface, since pack resolution (11 §6) can change what else is in that table without this campaign changing at all.
12. Reason Codes, State Changes, Messages
Kind-agnostic base vocabulary; kinds extend it (Kind.reasonCodes). Clients never
string-match English (games/04-engine-specification.md §2.3).
type LocKey = string; // key into the string table; stable, additive, never renamed;
// `~` is reserved for response aliases (§7) — see below
type ReasonCode = string; // stable, machine-readable; additive, never renamed
const BASE_REASON_CODES = [
// the original seven — the kind-agnostic play vocabulary
"action_not_available", "unknown_action", "requirement_unmet",
"session_ended", "read_only_field", "check_succeeded", "check_failed",
// the pure engine kernel: createGame / submitAction / deserialize rejections
"unknown_campaign", "unknown_kind", "invalid_state",
// registry assembly (§10.1)
"string_conflict", "protected_string_key", "duplicate_campaign_id",
// the core's own Tier-1 checks (§11, §17)
"invalid_identifier", "invalid_loc_key", "missing_string_key",
"missing_kind_reason_message",
// the profile store (§7.1) — mirrors ProfileWarningCode
"profile_missing", "profile_corrupt", "profile_write_failed",
"profile_kind_data_unreadable", "profile_kind_data_rejected",
// the save-load boundary (§10.2)
"save_requires_migration", "migration_failed",
// host persistence (§7.2)
"unknown_session", "unknown_save", "storage_failure", "concurrent_modification",
// session lifecycle (§7.4)
"invalid_fork_point",
// the audit vocabulary — a `StateChange.reason`, not a rejection (below)
"achievement_unlocked",
// content-pack resolution (11 §7) — `resolvePacks`, `registry/packs.ts`
"pack_kind_mismatch", "duplicate_campaign_id_in_pack", "pack_dependency_missing",
"pack_dependency_version_conflict", "pack_dependency_cycle", "pack_override_unexpected",
// campaign composition (§10.4) — registry build, before validation
"include_missing", "include_version_mismatch", "include_kind_mismatch", "include_cycle",
"include_alias_collision", "attachment_host_missing", "compose_unsupported",
// content epochs (16 §4.3, §5.4) — publish and adoption
"content_version_conflict", "content_kind_changed", "content_not_adoptable",
"content_incompatible",
// durable epochs (§4; 16 §4.3, §5.1, §7) — publish, restore and the host's catalog
"content_provenance_missing", "historical_string_collision", "epoch_unsupported",
"campaign_version_unsupported",
] as const;
The base set grows; it was never fixed at the MVP. The original seven were the vocabulary one turn needs. Everything after them was added by a unit that found a cross-kind failure mode with no code that fitted — the kernel's three rejections, registry assembly's three, the core's own Tier-1 four, the profile store's five, the save boundary's two, host persistence's four, session lifecycle's one, the audit vocabulary's one, content-pack resolution's six, campaign composition's seven, content epochs' four, and durable epochs' four. That is the intended shape: a code is registered when a real caller produces it, not pre-declared from this list.
campaign_version_unsupportedis the one base code the engine does not itself produce: a host answers it (409) when a request names a version whose restore wasunsupported(§4), and it is registered here so its message ships with the core rather than with each host. It is distinct from a host's owncampaign_version_unavailable, which means the version is not offered at all. Composition's seven were the one batch this contract named before their caller existed; they were specified with that caller (§10.4), and W120 shipped them inreasons.ts. BecauseReasonCodeis 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 withsrc/engine/src/core/kernel/reasons.ts, which is where the shipped set and its messages actually live.
The core ships their strings. Every base code has a default-English message under a
reserved core.reason.* namespace (core.reason.unknown_action, …), shipped with the
engine. Registry construction (§10.1) merges core strings with kind and campaign strings
and rejects any attempt to write a reserved key — a campaign cannot restyle what
an engine-level error says, because clients and tooling depend on those meanings being
stable. A key is reserved if it starts with core.reason., or is any other key in the core
catalog, or is a key in a registered kind's reasonMessages; the rejection is
protected_string_key with details.reason: "engine_reserved". A key containing ~ is
rejected too, with details.reason: "response_alias_namespace": that character is where
response aliases live (§7), and no engine key contains it — a test asserts so. The shape check
that already exists, LOC_KEY_SHAPE in src/engine/src/core/validation/tiered.ts, is
enforced for titleKey only and excludes ~ there; the reservation above is what covers every
other authored key. ContentArchive.publish applies both rules; restore tolerates a key that
was not reserved at publication (§4, historical_string_collision), because refusing it would
strand a session that was legal when it began. Kinds own the strings for codes they add (Kind.reasonCodes); campaigns own
their narrative strings. Validation fails if any registered reason code has no localized
message — that is what makes "clients never string-match English" enforceable rather than
aspirational.
interface StateChange {
path: string; // audit record, not a write path (games/04-engine-specification.md §10.4)
op: "set" | "increment" | "decrement";
value: string | number | boolean;
previous?: string | number | boolean;
reason: ReasonCode;
visible: boolean;
}
interface OutcomeMessage {
key: LocKey;
params?: Readonly<Record<string, string | number>>;
tone?: "neutral" | "positive" | "negative" | "absurd";
visible: boolean;
}
interface CommandResult<T> { ok: boolean; value?: T; errors: ValidationError[]; warnings: ValidationWarning[]; }
interface ActionResult extends CommandResult<GameState> { changes: StateChange[]; messages: OutcomeMessage[]; }
StateChange is an audit record emitted by typed reducers, never the mutation
mechanism — the discipline the simulation kind arrived at (games/04-engine-specification.md §10.4). It feeds
history and the transparency requirement; visible gates what a client receives, and the
session store applies that gate (§7).
StateChangeis not logging. It is a domain record: localized, returned inAdvanceResult, 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.reasonis a registeredReasonCodelike any other, and both of these are registered.StateChange.reasonis typedReasonCode, andvisiblegates 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_unlockedis base vocabulary (core.reason.achievement_unlocked) because the session store's profile upsert (§7.1) switches on it without knowing which kind emitted it, whileconsequence_appliedis kind-owned (story-graph.reason.consequence_applied,src/engine/src/kinds/story-graph/reasons.ts) because only that kind has a consequence. A kind inventing a third audit reason registers it the same way; there is no separate audit namespace exempt from §12's completeness rule.
Kind-owned reason codes carry their own messageKey namespace, distinct from event
names. A kind's Kind.reasonCodes need a localized message the same way the base set
does (above), and the convention is <kindId>.reason.<code> — no kind. wrapper, unlike
05-observability.md §9's event namespace,
kind.<kindId>.<event>. The two are easy to conflate because they differ only by one
segment, which is exactly why this needs stating rather than assuming: a reason-code
message key and an event name are different vocabularies serving different consumers
(one renders to a player, one traces a resolution), and neither is a namespace for the
other. story-graph.reason.unknown_condition_field
(src/engine/src/kinds/story-graph/reasons.ts) is the shipping example — a code this kind adds for a
condition-evaluation failure with no analogue in the base set, so it has no home but a
kind-owned namespace.
13. The MCP Surface
The tool table itself — args, returns, one tool per session-store operation — moved to
SubZeroDev.ServiceContract's
mcp-tool-contract.md:
it's a hosting-facing contract, not core engine material, even though McpTools (implemented
in src/engine/src/mcp/server.ts; see Engine Package) wraps this
repo's own session store (§7) with no runtime
dependency, tested end to end against it (TODO.md W17). Architecture §10 still holds:
no game logic in the adapter, no AI-specific path.
14. Determinism Harness
The acceptance test with teeth (MVP §5, games/04-engine-specification.md §18.4): a { config, actionLog }
fixture replays to a byte-identical serialize().
interface PlaythroughFixture {
name: string;
config: NewGameConfig; // includes a fixed seed
actionLog: LoggedEntry[]; // §2 — content entries replay too (C21)
}
// runner: createGame(config, starting version) → for each entry: an action → submitAction,
// a content entry → adoptContent(state, entry.to), which must adopt → serialize
The starting version is derived from the log exactly as §2 states, and a fixture whose log
crosses an epoch needs an engine whose archive resolves every epoch it names. A content entry
that refuses on replay fails the fixture, the same as an action that rejects, and so does a
content entry whose from is not the replay's current campaignVersion, checked before
adopting (90-decisions.md, 2026-10-09, S130). A migration entry
fails it outright: it is not a step replay can perform (§10.2).
- Golden files — committed fixtures with expected
serialize()output; a one-byte diff catches an unintended behaviour change across the whole engine. - Property tests — N random seeds, each run twice, outputs compared; catches non-determinism on paths no fixture touches.
- Sink independence — every fixture replays twice, once with
nullEmitterand once withcreateRecordingEmitter(), and bothserialize()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.mdspecifies it.
15. How the Story-Graph Kind Plugs In
Concrete mapping — and the reconciliation this document forces on
03-story-graph-kind.md.
| Core concept | Story-graph realization |
|---|---|
GameState.kindState | StoryGraphKindState — current node, variables, turn, visit counts, unlocked achievements, ending id |
Kind.advance(actionId) | submitChoice → settle (03 §8.2); actionId is the choice id |
AvailableAction | a node choice, gated by showWhen / requirements (03 §4) |
SceneBody | the node's textKey, interpolated (03 §3.1) |
Kind.project | StoryGraphView (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.validateCampaign | 03 §11 |
Kind.validateState | 03 §8.1 — every field typed, currentNodeId an existing node, every declared variable present with its type |
Kind.outcome | 03 §8.5 — terminalId is the endingId; terminal is "settled onto an EndingNode" (§3.2) |
Kind.terminalCount | 03 §8.5 — distinct endingIds across the campaign's EndingNodes; the denominator in CampaignProgress (§7.3) |
RngHandle.weightedPick | random-transition node resolution (03 §3) |
Kind.composeContent | 03 §1.1 — prefixed module content, a synthesized entry and exits, appended attachment choices. The only kind that implements it |
Reconciliation (done in 03). Writing this seam exposed that
03's state duplicated envelope-owned fields —version,campaignId,campaignVersion,seed,status, and the choice log. Those belong to theGameStateenvelope (§2), not the kind.03§8.1 now definesStoryGraphKindStateas 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;turnstays 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:
- The pure
Engine(§4) —createGame,submitAction,scene,view, serialize. - The
SessionStore(§7) and theProfileStorebeside it (§7.1). - The registry and its authoring builder (§10.1).
- The story-graph
Kindimplementation (§3, §15) against03-story-graph-kind.md. - The determinism harness (§14) — now that fixtures have a type.
- 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 id | Shape | Example |
|---|---|---|
| Campaign | kebab-case | bulgaria-bureaucracy |
| Node | snake_case | government_office |
| Choice | snake_case, unique within its node | begin_again |
| Variable | snake_case | office_visits |
| Achievement | snake_case | it_builds_character |
| Ending | snake_case | it_builds_character |
LocKey (localization) | dotted, type.id[.field] | event.pipe_disaster.title, choice.wait, stat.money |
| Reason code | snake_case verb/state | requirement_unmet |
Rules: ids are stable once published (a rename is a migration, §10.2); ids are ASCII
[a-z0-9_-] only; LocKeys namespace by content type so string tables stay navigable.
Tier-1 validation (§11) enforces the character set and uniqueness.
Composed ids (§10.4) are the one exception to the character set. When a host includes a
module under an alias, composition rewrites the module's kind-scoped ids — for story-graph its
node, variable and achievement ids (03 §1.1) — to <alias>::<id>. Nested includes chain:
a module that itself includes b contributes a::b::x to its host. : appears in no other
id, and no authored id may contain it: an authored id, alias or LocKey with a : fails
invalid_identifier (Tier 1), so a composed id can never collide with an authored one.
Each segment of a composed id still has its category's shape. Campaign ids, LocKeys and
reason codes are never composed.
18. Frozen Primitives
Two shared primitives are held deliberately small, because these are the surfaces that grow without bound if left open (a peer-review caution taken up-front).
The Condition operator set is closed. The comparison operators
(equals, not_equals, less_than, less_or_equal, greater_than, greater_or_equal,
in, not_in, contains, has_tag, has_flag) plus the tree combinators
(all/any/not) and quantifiers (exists/count) are the whole surface — shared with
the simulation kind (games/04-engine-specification.md §13.1). Tempting additions — between, matches,
arithmetic, inventory() / relationship() / distance() helpers, nested expressions —
are out unless a concrete campaign need justifies each one individually. Every
operator is permanent maintenance: a new one must be validated, evaluated, projected,
migrated, and taught to every tool. The bar to add is high on purpose.
The shape itself, as ported into this repository (core/condition/types.ts):
type ComparisonOperator =
"equals" | "not_equals" | "less_than" | "less_or_equal" |
"greater_than" | "greater_or_equal" | "in" | "not_in" | "contains" |
"has_tag" | "has_flag";
// `value` is optional: `not_equals` against an absent field is authored as `value: undefined`,
// and `JSON.stringify` drops an undefined-valued key, so the wire document never carries it.
interface ComparisonCondition { field: string; operator: ComparisonOperator; value?: unknown; }
interface AllCondition { all: Condition[]; }
interface AnyCondition { any: Condition[]; }
interface NotCondition { not: Condition; }
interface ExistsCondition { exists: { collection: string; where: Condition }; }
// count's own comparison is always two numbers (a match total against `value`) — only
// the six ordering/equality operators, never the array/string-shaped ones, which would
// type-check but always throw at evaluation.
type CountComparisonOperator =
"equals" | "not_equals" | "less_than" | "less_or_equal" | "greater_than" | "greater_or_equal";
interface CountCondition {
count: { collection: string; where: Condition };
operator: CountComparisonOperator;
value: number;
}
type Condition =
ComparisonCondition | AllCondition | AnyCondition | NotCondition |
ExistsCondition | CountCondition;
// What a caller supplies to the evaluator, which knows nothing itself about `var.*`,
// story nodes, or any other kind's field vocabulary.
interface ConditionResolver {
field(path: string): unknown;
collection(name: string): readonly ConditionResolver[];
}
One field of the ancestor's shape did not port. games/04-engine-specification.md
§13.1's Condition also carries a CollectionSelector — a closed union of
simulation-kind paths (player.inventory, world.npcs, …). None of those are
kind-agnostic, so collection here is a plain string, and which strings are legal is
entirely up to whichever kind resolves them (kinds/story-graph/conditions.ts for the one
kind that exists today). The ancestor citation above stays as provenance — per
CLAUDE.md, every games/… citation is provenance, not a second authority — but this
section, not that document, is now where the shape itself lives.
Reason codes are additive, never renamed (§12) — saves and replay logs reference them, so a rename breaks old data.
19. Published Narrative Authoring
The package root is the runtime contract. @the-running-dev/game-engine/authoring is the
separate author-time contract for repositories that own campaign source. It exports the
generic campaign builder, the story-graph source builder, the simulation source builder with
its content-definition source types, the shared adventure builder with its source factory and
its migration helper, portable serialization and manifest digests, and replay-runner
types/functions. It is deliberately a subpath: a runtime host must not import authored
campaign source merely to play published portable JSON.
A kind's export split follows one rule regardless of which kind it is: the builder and its
source types are author-time and belong on /authoring; the campaign, state, view and outcome
types are what a runtime host compiles against and belong at the root
(design/30-slices.md, W88). buildWorldGraphCampaign's root placement predates the subpath
and is an exception noted here rather than moved.
SubZeroDev.Adventures.Content owns the canonical source and publication of narrative
campaigns. GameEngine owns kinds, validation, portable hydration and authoring primitives.
GameEngine may retain a frozen campaign solely as a regression fixture; such a fixture is not
published and not listed in a manifest. Frozen fixtures left the package root in the breaking
0.9.0 release (design/30-slices.md, W74c), which has shipped — src/engine/package.json reads
0.10.0. That peg moved once before it landed: this section originally named 0.8.0 as the
breaking release, and 0.8.0 was then spent on an additive one. A version reserved by this
section is a name to check against src/engine/package.json before a bump, not after.
A third category is sanctioned and is neither of the two above: a kind's own reference
campaign — engine-owned content that exists to make the kind registrable and exercisable,
never authored by Content and never a frozen fixture — is a package-root export. The instance is
buildWorldGraphMvpCampaign / WORLD_GRAPH_MVP_CAMPAIGN_ID, the only content a host can
register the world-graph kind against. The line that keeps this from swallowing the rule: the
root publishes no narrative campaign, which is the claim src/engine/src/authoring.test.ts
enforces in both directions. A second kind adding a reference campaign to the root follows this
sentence; a second reference campaign for one kind does not, and wants its own decision.
Portable campaign documents remain format version 2. toPortable is public only through
/authoring; fromPortable remains a runtime-root export. Both digest helpers are exported from
both surfaces: the root, for hosts verifying fetched content, and /authoring, for authoring
pipelines digesting campaign source before publication. digestPortableCampaign digests one
campaign file. digestManifestResolution(entries: readonly PortableManifestEntry[]) digests the
ordered list of { id, version, digest } taken from the manifest's entries, so it identifies a
content set whose files a host has each verified against their entry digest. File paths are
excluded as transport details. The recipe includes digest from 0.12.0; until then it was the
ordered { id, version } list over PackRef[], so a manifest's resolution changes for
unchanged content (OPEN-QUESTIONS.md, 2026-10-27).
PortableCampaignBody's simulation arm gains an optional migration, and the format version
does not move. Until now migration existed only on the story-graph arm, which
src/engine/src/portable/format.ts documented as a structural fact rather than an omission —
correct while migrateFromContent was the only reattachable migration there was. It is not a
structural fact about the format; it was one about how many kinds had a declarative migration
written, and the simulation kind now has one
(10-simulation-kind.md §16). toPortable's throw narrows
accordingly: it still refuses a migration for world-graph, which has none.
The version stays at 2 because the change is additive and its silent-loss case cannot
happen. A 2 reader handed a document carrying a simulation migration ignores the member
and attaches no migrateState — which matters only when campaignVersion actually moved, and
that is precisely when resolveSaveEnvelope rejects the load with save_requires_migration
(§10.2). The old reader fails loudly at the exact moment the dropped field would have mattered,
so bumping to 3 would buy a louder failure for documents that load correctly and nothing for
the ones that do not. formatVersion is a literal type, so a bump also breaks every reader
including the ones that would have been fine.
20. The Ordered System Pipeline
Two kinds resolve a turn by running an ordered list of systems: simulation's end-of-week pass
(10-simulation-kind.md §3) and world-graph's tick
(12-world-graph-kind.md §4.1). Those orders are normative and are
owned there, not here. This section owns only the substrate they run on: what applying an
ordered list means, and what doing so must never do.
The substrate is engine-internal. It is exported from neither the package root nor /authoring,
and no host supplies, replaces, wraps, or observes one — a pipeline sits inside the determinism
boundary, which by 06-extensibility.md §2 is the same line as the trust
boundary.
It is a fold over an explicit list, and nothing more.
- Order is the caller's, verbatim. The substrate applies entries in exactly the order given. It never sorts, filters, deduplicates, reorders, or skips, and it holds no registry of systems and no opinion about which belong. A caller that supplies a list is the sole authority on its contents — which is what lets the two normative orders stay owned by their kinds while the mechanism is shared.
- Every entry runs, every time. There is no short-circuit and no early exit. A terminal or failed result is a value carried in the frame, never a control-flow signal; §4.1's rule that "a terminal result does not interrupt the tick" is a consequence of this, not an exception to it. Where a turn stops early it stops in the caller's own loop around the substrate, never inside it.
- Each entry is a total function from frame to frame, and the substrate threads each returned frame to the next entry. No entry observes a frame from anything but its immediate predecessor, and the substrate never inspects, merges, or reconstructs one. The frame type belongs to the caller; the substrate is generic over it and reads no field of it.
- The substrate emits nothing. It holds no
Emitter, draws no randomness, and reads no clock. Every event a turn produces is emitted by a system — and per-system trace events are no exception. Where a kind wants one, the entry's ownruncloses over the system and the emission together, at the point the list is built.simulationdoes this forkind.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-graphdeclares 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-entryprocessingTickguard 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 thesystem.ranstream 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.