Skip to main content

TODO

Status: Delivery ledger. All 125 W-numbered units have landed or been cancelled, and each is retired to one row under Landed. Slices from S121 on are ## S<n> sections above Landed, in the agent kit's slice format; each reads Status: todo until the pull request that builds it sets Status: done. S121–S128 fix the reproduced defects of the 2026-10-03 repository review, planned in plans/53. S129–S135 build content epochs, 16-content-epochs.md. S136–S137 make every kind refuse undeclared action params, the precondition for session capture (08-session-capture.md). S138 makes simulation refuse an id it never issued, the last such precondition, and S139 builds capture.

Where a retired unit's full text is. Every W unit's body — what it delivers, its numbered acceptance criteria, its scope, and the programme prose that grouped units together — is in this ledger as it stood at 7081402. Most units also have an execution record under plans/. Each row's anchor is the unit's id in lower case, so a link ending in #w90 still lands on W90's row.

The contracts the units were built against are unchanged: 04-core.md, 03-story-graph-kind.md, 10-simulation-kind.md, 12-world-graph-kind.md and 05-observability.md. The register of unknowns is OPEN-QUESTIONS.md.

Carried Forward​

Open items the retired bodies carried that were never sliced. They are moved here so that none is lost. Unless an entry says it is ready to plan, it needs a contract or design decision first.

  • Hosted engine edge. The .NET Platform edge → Node engine workload process boundary, proposed to follow W62: a generated JSON/HTTP service contract first, MCP as a projection, and one in-memory remote session before persistence, accounts, catalogue or metering.
  • More clients, such as Discord.
  • Content tooling not yet built. The visual node editor needs a design decision on where it lives, what it emits, and whether authored source or the built campaign is its file format. Content balancing tools are unbuilt. AI authoring assistants need a dependency decision, and W77's author-facing validator is their precondition.
  • Simulation balance. Drift rates, scenario economics, demandBand thresholds and the housing-quality formula are placeholder numbers. Balancing is game-side; the engine owes the levers, which §7.14 contracts and W117–W119 shipped.
  • Three docs-template hardening findings, to raise upstream, from the automated review of PR #3: the docs workflows pin the mutable :latest tag (comment); the link validator does not constrain resolved targets to the repository root (comment); and file enumeration walks excluded trees before filtering them (comment).
  • The games' own Definitions of Done live with the games: games/life-in-the-fast-lane.md and games/bulgaria-adventure.md in SubZeroDev.GameOfLife.

S121 — Hidden Outcomes Stay Hidden​

Status: done Delivers: A player, or a rival AI, reading what an action did sees only what the campaign meant them to see. A hidden variable's new value or a hidden message no longer arrives in the response, so no client can reveal it. Touches: src/engine/src/core/session/store.ts, src/engine/src/core/session/types.ts, 20-contract.md §7 and §12 Depends on: none Acceptance:

  • S121.1 submitAction and previewAction return only StateChange and OutcomeMessage records whose visible is true, on accept and on reject. The whole serialized result contains no hidden record's value.
  • S121.2 The gate applies to every audience, ai included.
  • S121.3 The profile fold still reads the unfiltered result: an achievement unlocked by a visible: false change is recorded on the profile, and is absent from the returned changes.
  • S121.4 The MCP choose and preview_action tools carry the same filtered result. The review's probe campaign, a hidden story-graph variable set by a choice, leaks nothing through either.
  • S121.5 The pure engine's ActionResult is unchanged and still carries every record. Out of scope: Filtering inside the engine, Scene or PlayerView (already projections), and the attempt counter and revision (S122).

S122 — A Rejected Action No Longer Strands a Shared Session​

Status: done Delivers: A host running several engine instances over one database can tell a real collision from a rejected move. A player who submits an invalid action, or previews one, can keep playing, and a player who loses a genuine race refreshes once and continues from the winner's move. Touches: src/engine/src/core/session/store.ts, src/engine/src/core/session/types.ts, 20-contract.md §7.2 Depends on: S121 Acceptance:

  • S122.1 StoredSessionRecord.revision is 0 when createSession, loadGame or branchSession first writes a record, and rises by one on each accepted submitAction write. A rejected submission leaves it unchanged, so the next valid action commits under an adapter that holds §7.2's compare-and-swap rule.
  • S122.2 previewAction leaves it unchanged, with the same consequence.
  • S122.3 Two store instances over one compare-and-swap adapter: the losing write raises concurrent_modification, and its retry re-reads persistence and builds on the winning write.
  • S122.4 storage_failure restores the cached record and keeps it, without re-reading persistence. Every field the refused put carried is rolled back.
  • S122.5 A submission already queued behind a conflicted write is refused as well. It is never committed as a successor of the refused write.
  • S122.6 A record read from persistence is the store's own copy, so an adapter that returns its stored object by reference still compares correctly.
  • S122.7 §7.2 states the rule: compare revision, never attemptCounter. Out of scope: Adventures' migration (a revision column, its compare-and-swap predicate, and the branded conflict). That lands in Adventures after an engine release.

S123 — A Save Exists Only Once It Is Stored​

Status: done Delivers: A player whose save failed is never shown that save in their list or offered it to load, so a save that would vanish on the next restart is never presented as kept. Touches: src/engine/src/core/session/store.ts, 20-contract.md §7.2 Depends on: S122 Acceptance:

  • S123.1 When saves.put throws, saveGame raises storage_failure, and listSaves on the same instance does not include the refused save.
  • S123.2 loadGame of the refused save's id raises unknown_save.
  • S123.3 A fresh store instance over the same adapter agrees with S123.1 and S123.2.
  • S123.4 The session survives the failure: the next saveGame is durable, listed, and loadable from a fresh instance.
  • S123.5 createSession, loadGame and branchSession already write before caching. This was audited and left unchanged. §7.2 states the general rule: a failed write leaves no cache trace. Out of scope: Bounding the save cache (S126/S127, D4).

S124 — The Engine Owns What It Logs​

Status: done Delivers: A host that reuses or edits its params object after submitting an action can no longer rewrite a player's recorded history, so a save replays the game they played. Touches: src/engine/src/core/kernel/engine.ts, 20-contract.md §4 Depends on: none Acceptance:

  • S124.1 Mutating the caller's params object after submitAction or previewAction leaves the returned state's serialize() output and its actionLog unchanged.
  • S124.2 Kind.advance receives the same copy the LoggedAction records, never the caller's object.
  • S124.3 Callers that never mutate get byte-identical output, so the determinism and replay suites pass unchanged.
  • S124.4 Host-supplied campaigns were audited and left unchanged: the registry holds them by reference. Recorded in 90-decisions.md, Found by the 2026-10-03 repository review. Out of scope: Copying or freezing campaign content (S124.4's entry; D3's content epochs).

S125 — A Session Plays Only the Kind Its Campaign Was Written For​

Status: done Delivers: A stored session whose kind disagrees with its campaign's is refused when it is loaded, rather than handing that campaign's content to the wrong game's rules. Touches: src/engine/src/core/kernel/engine.ts, 20-contract.md §4, 10-design.md 05 §8 Depends on: none Acceptance:

  • S125.1 deserialize rejects a state whose kindId is registered but is not its campaign's kindId, with invalid_state (path: "kindId"), and emits one core.deserialize.rejected with reason: "invalid_state".
  • S125.2 migrate rejects the same state the same way.
  • S125.3 A state whose kind agrees with its campaign still round-trips byte-identically with no rejection event.
  • S125.4 The save-envelope path still migrates: resolveSaveEnvelope's own cross-check and its migration suite pass unchanged.
  • S125.5 campaignVersion is still not compared on the raw path. That gap is recorded in 90-decisions.md, Content epochs, and is not fixed here. Out of scope: Any campaignVersion policy (D3, content epochs); kind-owned kindState validation (S126).

S126 — A Kind Refuses a State It Cannot Run​

Status: done Delivers: A save or stored session whose game data is the wrong shape is refused when it is loaded, rather than being accepted and failing on the first move. Touches: src/engine/src/core/kernel/types.ts, src/engine/src/core/kernel/engine.ts, src/engine/src/core/persistence/envelope.ts, each kind's validateState.ts, 20-contract.md §3, §4, §10.2, 03 §8.1, 10 §2, 12 §3, 10-design.md 05 §8 Depends on: S125 Acceptance:

  • S126.1 Kind gains a required validateState(kindState, campaign): boolean. deserialize calls it after its own checks. A false or a throw rejects with invalid_state (path: "kindState") and emits one core.deserialize.rejected; migrate inherits it.
  • S126.2 resolveSaveEnvelope asks the kind after both migrations. A refusal is invalid_state for an unmigrated save and migration_failed for a migrated one.
  • S126.3 Story-graph checks every field's type, that currentNodeId names a campaign node, and that every declared variable is present with its VarType; an undeclared variable is tolerated.
  • S126.4 Simulation and world-graph check each top-level field's type. Simulation tolerates an absent resolution, because sessions saved before it existed do not have one.
  • S126.5 Every state each replay corpus fixture passes through round-trips through deserialize. Out of scope: Deep validation of simulation and world-graph records (recorded in 90-decisions.md, Found by the 2026-10-03 repository review).

S127 — A Long-Running Host Stops Accumulating Memory​

Status: done Delivers: A host that serves many sessions over a long uptime no longer holds every session, save and lock it has ever touched. Lock entries go when their work does, and a host with persistence can cap how many sessions stay in memory. Touches: src/engine/src/core/session/store.ts, src/engine/src/core/composition/types.ts, 20-contract.md §7.2, 10-design.md 06 §5.2 Depends on: S126 Acceptance:

  • S127.1 A lock entry is removed when its last queued operation settles, and only while it is still the queue's tail. The lock maps are empty after N sequential and N concurrent runs, and an operation queued behind a settling one keeps its place.
  • S127.2 SessionHost.sessionCacheLimit bounds the session cache, least recently used first, when persistence is supplied. Omitted, the cache is unbounded. The limit is refused when it is not a positive integer, or when persistence is absent.
  • S127.3 An evicted session reloads from persistence and continues identically: the same scenes and the same stored rows as an unbounded run.
  • S127.4 A session a command is using is never evicted, and concurrent reads of an uncached session share one adapter call, so concurrent commands under a limit never conflict.
  • S127.5 With persistence, saves are not held in memory: loadGame, deleteSave and listSaves read the adapter every time. Out of scope: Cache, session and blob metrics (recorded in 90-decisions.md, Found by the 2026-10-03 repository review).

S128 — The Package Says What It Is Licensed Under​

Status: done Delivers: The published engine package declares its licence and carries the licence text, and a pull request that changes only the consumer smoke still runs it. Touches: src/engine/package.json, src/engine/scripts/stage-license.mjs, src/engine/scripts/verify-release.mjs, .github/workflows/ci.yml, .github/workflows/release-engine-package.yml, agent.md Depends on: S127 Acceptance:

  • S128.1 package.json declares "license": "MIT", and the packed archive contains package/LICENSE. The text is the repository root's, copied in for the pack and removed after it; the tree tracks one copy.
  • S128.2 The release archive guard rejects an archive without package/LICENSE, and the CI engine job inspects its tarball with that guard rather than an inline copy of its rules.
  • S128.3 The engine job's change detector diffs every input it reads: src/engine, consumer-smoke, LICENSE and its own workflow. A consumer-smoke-only change runs it.
  • S128.4 agent.md points at npm test for the suite's size instead of stating a count. Out of scope: Generating the docs' other volatile facts, one of the review's non-defect recommendations (plans/53-repository-review-2026-10-03-fixes.md §3).

S129 — Every Game Plays the Content It Names​

Status: done Delivers: A host can keep more than one published version of a campaign available at once. A new game can start on any of them, and every game plays the version it names rather than whichever version the host registered under the campaign's name. A stored game naming a version the host does not hold is refused when it is loaded, instead of being played against the wrong content. Touches: src/engine/src/core/kernel/types.ts, src/engine/src/core/kernel/engine.ts, src/engine/src/core/composition/types.ts, src/engine/src/index.ts, 20-contract.md §4, 10-design.md 06 §4 and Content Epochs §4.1 Depends on: S128 Acceptance:

  • S129.1 EngineHost gains archive?: ResolutionArchive. Without one, the engine uses an archive holding only registry. Engine.content exposes whichever archive is in use. createEngine throws when the archive does not resolve every campaign of registry at its registered version.
  • S129.2 scene, view, availableActions, submitAction, previewAction, deserialize and migrate take KindContext.registry and KindContext.campaign from content.resolve(state.campaignId, state.campaignVersion). One engine over an archive holding two epochs of one campaign, which differ in one node's text, renders each epoch's own text for a state on that epoch.
  • S129.3 createGame(config, campaignVersion?) starts on the registry's version when no version is given and on the named epoch when one is, stamping it as campaignVersion. A version that does not resolve fails with unknown_campaign.
  • S129.4 deserialize and migrate reject a state whose (campaignId, campaignVersion) does not resolve with unknown_campaign and one core.deserialize.rejected. This includes a campaign id the registry holds at another version, which closes the gap S125.5 recorded.
  • S129.5 With no archive supplied, every existing test, golden file and replay corpus fixture passes unchanged.
  • S129.6 ResolutionArchive is exported from the package root as a type. Out of scope: Adoption and epoch log entries (S130); the reference archive (S131); the session store choosing a starting version (S133).

S130 — A Game Can Move Onto New Content, and Replays Exactly Across It​

Status: done Delivers: A running game can move onto another published version of its campaign as a recorded step in its own history, or stay where it is with a stated reason. Replaying that history reproduces the game byte for byte. A game that never moves is stored exactly as it was before. Touches: src/engine/src/core/kernel/types.ts, src/engine/src/core/kernel/engine.ts, src/engine/src/core/kernel/reasons.ts, src/engine/src/core/observability/events.ts, src/engine/src/core/determinism/harness.ts, src/engine/src/index.ts, 20-contract.md §2, §3, §4, §12, §14 and C21–C26, 10-design.md Content Epochs §3 and §5.4 Depends on: S129 Acceptance:

  • S130.1 The actionLog element type is LoggedEntry, a union of LoggedAction, LoggedContent and LoggedMigration. The starting version is derived: the first epoch entry's from, else campaignVersion. An epoch entry consumes a seq, so an action after one derives its stream from the full log length.
  • S130.2 Engine.adoptContent(state, to) runs 16 §5.4's steps 0–8 in order. One test per pinned outcome asserts its reason: session_ended, unknown_campaign, content_kind_changed, content_not_adoptable, migration_failed, content_incompatible for both a refusal and a throw, and invalid_state. A pinned result carries no state, and the input state is deep-equal to what it was before the call.
  • S130.3 An adoption appends { seq, system: "content", from, to }, sets campaignVersion to to and formatVersion to 2, and emits one core.content.adopted with fromVersion and toVersion. A refusal emits one core.content.pinned with reason. to equal to the state's own version returns the state unchanged. AdoptionResult has no changes or messages, and no stream is derived for an adoption (C24).
  • S130.4 deserialize rejects with invalid_state a formatVersion: 1 state whose log carries an epoch entry, a state whose last epoch entry's to is not its campaignVersion, and a formatVersion above 2. It accepts a version-2 state that satisfies C26, and does not resolve a migration entry's from. The PR states the positive and negative case counts.
  • S130.5 The §14 harness takes actionLog: LoggedEntry[]. It creates the game on the starting version and replays each content entry through adoptContent, which must adopt. A fixture crossing an epoch, built on a test-only kind that declares adoptContent, serializes byte-identically after every entry across two runs (C21). A content entry that now refuses fails the fixture, and so does any migration entry.
  • S130.6 A state with no epoch entry keeps formatVersion: 1. Every golden file and replay corpus fixture is unchanged.
  • S130.7 Kind gains the optional adoptContent, and content_kind_changed, content_not_adoptable and content_incompatible join the base reason codes with their message keys. LoggedEntry, LoggedContent, LoggedMigration, AdoptionResult and AdoptDecision are exported from the package root. Out of scope: Any shipped kind's adoptContent (S132 for story-graph; simulation and world-graph stay pinned, per the contract's Unresolved); writing migration entries (S134).

S131 — A Host Publishes New Content While Running​

Status: done Delivers: A host can publish a new build of its content without restarting. The engine validates every campaign before accepting it, refuses to let one version name stand for two different contents, and offers the latest publication to every session that asks. Touches: a new archive module under src/engine/src/core/registry/, src/engine/src/core/kernel/reasons.ts, src/engine/src/index.ts, 20-contract.md §4 and C23, 10-design.md Content Epochs §3.4, §4.3 and §5.1 Depends on: S130 Acceptance:

  • S131.1 createContentArchive({ kinds, initial }) resolves every campaign of initial at its version. It throws when initial fails validation.
  • S131.2 publish re-runs every campaign's validateCampaign against registry.strings. Any error refuses the publication with those errors; nothing is stored and latest() is unchanged.
  • S131.3 Content identity is 16 §3.4's digest: 11 §6's campaign digest plus the registry's full strings table, sorted by key. A held key whose digest differs is refused with content_version_conflict, and nothing is stored. A change to one string under an unchanged version is such a conflict. An identical republish succeeds and returns no epochs; a new key returns its EpochRef.
  • S131.4 What the archive serves is a deep-frozen copy: mutating the registry after publish returns changes nothing resolve answers. migrateState is kept by reference.
  • S131.5 latest() is the last publication that succeeded. current(scope) answers that publication's version of scope.campaignId for any session or profile, and undefined for a campaign it does not hold.
  • S131.6 An engine whose archive is a content archive keeps playing a game created on one epoch, unchanged, after a newer epoch of the same campaign is published (C22).
  • S131.7 content_version_conflict joins the base reason codes. createContentArchive, ContentArchive, ContentChannel, ContentScope and EpochRef are exported from the package root. Out of scope: A durable archive and eviction, and listing a campaign that first appears in a publication (both in the contract's Unresolved).

S132 — A Story Moves Onto Additive Content​

Status: done Delivers: A player partway through a story can be moved onto a newer version of it that only adds to what they are playing. They keep their place, their progress and their achievements. A version that would strand them where they stand, or drop something they carry, leaves them on the version they started. Touches: a new adoption module under src/engine/src/kinds/story-graph/, src/engine/src/kinds/story-graph/kind.ts, 20-contract.md 03 §8.1, 10-design.md Content Epochs §5.5 Depends on: S130 Acceptance:

  • S132.1 Story-graph declares adoptContent. It adopts a target that adds nodes, choices and variables, inserting each variable the target declares and the state lacks at its declared initial value and leaving everything else unchanged.
  • S132.2 It refuses with content_incompatible each of the following, one test per case: a currentNodeId the target lacks; a current node that is an ending, auto or random node in the target; a current choice node with no available choice once the inserted variables are applied; a visitedCounts key that names no target node; an unlocked achievement the target lacks; a carried variable whose target VarType differs; and a variable the source declared and the state carries that the target does not declare.
  • S132.3 Availability is judged by the same showWhen and requirement evaluation the scene uses. A choice gated only by an inserted variable's initial value counts as available exactly when the scene would show it as available.
  • S132.4 Every adopted state passes validateState against the target campaign. The Bureaucracy campaign adopting a copy of itself extended by one node and one variable replays through the §14 harness byte-identically. Out of scope: Simulation and world-graph adoption rules (the contract's Unresolved); authoring guidance for compatible content.

S133 — Running Sessions Pick Up New Content​

Status: done Delivers: A player's session moves onto newly published content between their moves: when they submit an action or come back to the game. A refusal, or a host that fails to answer, never costs them a move. Text they have already been shown never goes missing from the session's strings. Touches: src/engine/src/core/session/store.ts, src/engine/src/core/session/types.ts, src/engine/src/core/composition/types.ts, 20-contract.md §7 and C25 and C27, 10-design.md 06 §4 and Content Epochs §5.2, §5.3 and §5.7 Depends on: S131, S132 Acceptance:

  • S133.1 SessionHost gains content?: ContentChannel. createSession asks it once and passes the answer to createGame. The new game's log carries no content entry.
  • S133.2 At submitAction, after an accepted action commits, the store asks the channel under the session lock. A different version is passed to adoptContent, and the result is persisted in the action's own write. The returned messages come from the old epoch and the scene from the new one.
  • S133.3 At resumeSession, adoption is persisted in its own write at revision + 1. When that write fails with storage_failure or concurrent_modification, the store restores the unadopted state, returns its scene, and the next adoption point offers again.
  • S133.4 A refusal leaves the session pinned and the command completes. A channel that throws is read as no offer. Neither is an error result.
  • S133.5 Queries never consult the channel, and neither does previewAction or a rejected action. A recording channel shows zero calls across them. With no channel, every existing session-store test passes unchanged.
  • S133.6 getStrings returns the union of the session's live epochs, each key from the most recent epoch that defines it. A key the adopted epoch dropped still resolves; a key both define resolves to the newer text; a session that never adopted gets exactly its one table. Out of scope: Adoption at loadGame and branchSession (S134); catalog refresh.

S134 — Saves and Branches Carry Their Epochs​

Status: done Delivers: A save made on an older version of a campaign loads exactly as it was made when that version is still held, then moves forward like any session, and stays replayable. Branching a session that moved onto new content replays it through the same moves, or says plainly why it cannot. Touches: src/engine/src/core/session/store.ts, src/engine/src/core/persistence/envelope.ts, 20-contract.md §7.4, §10.2 and B1, 10-design.md Content Epochs §3.5, §5.3 and §5.6 Depends on: S133 Acceptance:

  • S134.1 A save whose epoch engine.content resolves, and whose kindVersion is the registered kind's, loads on that epoch with no migration and keeps replayCompatible. loadGame then reaches its adoption point and persists the result in the new session's first write.
  • S134.2 Any other save takes §10.2's migration path. A migration that changes campaignVersion on a log that carries an epoch entry appends { seq, system: "migration", from, to } and keeps formatVersion: 2, so the result passes deserialize (C26). A migration on a log with no epoch entry appends nothing and keeps formatVersion: 1. A migration that changes only kindVersion appends nothing. Migrating the migrated save again appends no second entry.
  • S134.3 branchSession replays the retained prefix from the starting version: actions through submitAction, content entries through adoptContent, and then reaches its own adoption point. atActionCount counts entries of every type. A fork before an adoption starts on the old epoch.
  • S134.4 branchSession refuses with nothing written: invalid_state for a log carrying a migration entry, invalid_state for a recorded adoption that now refuses, and unknown_campaign for a starting or adopted version that no longer resolves.
  • S134.5 B1 holds widened: with no offer at its adoption point, branchSession(S, n) serializes byte-identically to the prefix of S across a content entry. Out of scope: Capture refusing a replayCompatible: false session (capture is unbuilt; recorded in 90-decisions.md's open register).

S135 — The Replay Oracle Crosses Content Epochs​

Status: done Delivers: A recorded play session that moved onto new content partway through is a regression fixture like any other. A later engine version replays its adoptions in place, and reports a divergence if the kind would now refuse one. Touches: src/engine/src/core/replay/types.ts, src/engine/src/core/replay/runner.ts, src/engine/fixtures/replay/, src/engine/src/index.ts, 10-design.md 07 §2, §3 and §6 Depends on: S134 Acceptance:

  • S135.1 Submission is ActionSubmission | AdoptionSubmission, and Decision is ActionDecision | AdoptionDecision. Every existing fixture and recorded outcome reads, and matches, unchanged.
  • S135.2 The runner resolves the fixture's campaignVersion and every adopted version through the engine's content before running. campaign_withdrawn still means the campaign id is gone; campaign_version_missing now means any version the fixture names does not resolve. It creates the game with createGame(config, fixture.campaignVersion).
  • S135.3 An adoption runs through adoptContent. A refusal records an AdoptionDecision with accepted: false and its reason, and the verdict is diverged at that index. An adoption counts in decisions and never in acceptedActions.
  • S135.4 The corpus gains one fixture that crosses an epoch, with its recorded outcome, run by the corpus suite against an archive holding both epochs. Out of scope: Capturing fixtures from hosted sessions (session capture remains carried forward).

S136 — Simulation Refuses Details It Did Not Ask For​

Status: done Delivers: A Life in the Fast Lane player's week holds only what the game asked them for. A client can no longer slip extra details into a planned action and have them saved with the game, and a session recorded before this change that carries such a detail says plainly that it cannot be re-run, instead of failing obscurely. Touches: src/engine/src/kinds/simulation/advance.ts, src/engine/src/kinds/simulation/reasons.ts and its strings, src/engine/src/core/session/store.ts, tests alongside; 20-contract.md 10 §4 and §10, 04 §7.4; 90-decisions.md Depends on: none Acceptance:

  • S136.1 plan.add with { actionType: "borrow_money", amountCents: 5000, choiceId: "x" } returns unexpected_params / simulation.reason.unexpected_params, appends nothing to the action log, and leaves serialize(state) byte-identical.
  • S136.2 A key declared only for another actionType is rejected: { actionType: "operate_business", amountCents: 100 } returns unexpected_params.
  • S136.3 Every declared shape in 10 §4 is accepted with its pre-change outcome: each of borrow_money, repay_debt, deposit_savings and invest with amountCents; respond_to_event with choiceId; operate_business with close; and any type with or without targetId. The stored GameAction.parameters holds only declared keys.
  • S136.4 plan.remove with { index: 0, extra: 1 }, and plan.clear or end_week with any non-empty params, return unexpected_params and append nothing.
  • S136.5 unexpected_params is registered in SIMULATION_REASON_CODES with a strings-catalog message, and the reason-completeness guard passes. 10 §10's row reads dispatched.
  • S136.6 Every simulation fixture in src/engine/fixtures/replay/ (stable-life-, bulgaria-stable-life-, long-horizon-) replays to its recorded outcome unchanged.
  • S136.7 kindVersion stays 1.1.0. A save serialized before the change loads with replayCompatible intact and continues play. 90-decisions.md records why there is no bump: the rule changes advance, not state shape, and only a log that already carries an undeclared key is affected.
  • S136.8 branchSession over a log whose retained prefix holds an action the kind now rejects throws SessionStoreError invalid_state with nothing written, as it does for a refused adoption, and no longer throws a plain Error. 04 §7.4 lists the refusal. A test with a hand-built simulation log carrying an undeclared key fails when the fix is reverted. Out of scope: Checking targetId and choiceId against content at plan.add (08 §9's value review, still design work); capture itself, which has no contracted signature.

S137 — World-Graph Refuses Details It Did Not Ask For​

Status: done Delivers: A Sun Trap player's recorded actions carry only the details each action declares, so a recorded world-graph session holds nothing a client chose to tack on. Touches: src/engine/src/kinds/world-graph/advance.ts, src/engine/src/kinds/world-graph/actions/common.ts, src/engine/src/kinds/world-graph/reasons.ts and its strings, tests alongside; 20-contract.md 12 §6 and §11 Depends on: S136 Acceptance:

  • S137.1 build with { definitionId, x, y, rotation, note: "hi" } returns unexpected_params / world-graph.reason.unexpected_params, appends nothing, and leaves serialize(state) byte-identical.
  • S137.2 For each action id in 12 §6's table, its declared keys are accepted with the pre-change outcome, and one added undeclared key returns unexpected_params. assign_staff accepts its optional zoneId and buildingId present or absent.
  • S137.3 The key check runs before any value is read: advance_ticks with { ticks: "bad", extra: 1 } returns unexpected_params, not unknown_action.
  • S137.4 unexpected_params is registered in WORLD_GRAPH_REASON_CODES with a strings-catalog message, and the reason-completeness guard passes. 12 §11's row reads dispatched.
  • S137.5 Every world-graph-mvp- fixture replays to its recorded outcome unchanged, and batch invariance (12 §5) still holds.
  • S137.6 kindVersion stays 1.0.0. A save serialized before the change loads and continues. Out of scope: Session capture; any change to how world-graph checks id values, which are already checked against content or state.

S138 — Simulation Refuses Ids It Never Issued​

Status: done Delivers: A Life in the Fast Lane player plans only around what the game put in front of them: a job, course, home, item, place, person, opportunity, project, business or answer it offered. A client can no longer plan around a name it made up and have that name saved with the game, so a recorded week holds nothing the game did not issue itself. Touches: src/engine/src/kinds/simulation/advance.ts, src/engine/src/kinds/simulation/reasons.ts and its strings, tests alongside; 20-contract.md 10 §10 Depends on: none Acceptance:

  • S138.1 plan.add with { actionType: "apply_for_job", targetId: "job-never-issued" } returns unknown_target / simulation.reason.unknown_target, appends nothing to the action log, and leaves serialize(state) byte-identical.
  • S138.2 For each domain in 10 §4's table — job, course, housing, item definition, inventory instance, location, NPC, opportunity in state, project, project instance, business, business instance — one plan.add naming an id outside it returns unknown_target, and one naming an id inside it is accepted with its pre-change outcome.
  • S138.3 respond_to_event whose targetId names no pending response returns unknown_target; one naming a pending response with a choiceId outside its availableChoiceIds returns unknown_target; one with a choiceId inside them is accepted.
  • S138.4 An id minted by a pending entry is accepted, and stops being accepted when that entry leaves the plan: after shop is added as action-<n>, sell_item with targetId: "inv-action-<n>" is accepted; after that shop is removed by plan.remove, or the plan is emptied by plan.clear, the same sell_item returns unknown_target. The same holds for start_project → work_on_project (project-action-<n>) and start_business → operate_business (business-action-<n>).
  • S138.5 None of the twelve types without a target — work, work_overtime, search_for_work, negotiate_job_terms, eat, rest, exercise, pay_bills, borrow_money, repay_debt, deposit_savings, invest — accepts targetId: { actionType: "rest", targetId: "loc-1" } returns unexpected_params. The existing test that asserts rest stores a targetId asserts the rejection instead.
  • S138.6 The check runs last: { actionType: "teleport" } returns unknown_action; { actionType: "apply_for_job", targetId: "job-never-issued", amountCents: 1 } returns unexpected_params; and while an event response is pending, { actionType: "apply_for_job", targetId: "job-never-issued" } returns event_response_pending.
  • S138.7 What plan.add does not check is unchanged: { actionType: "apply_for_job" } with no targetId is still accepted, and affordability, requirements, active enrollment and plan order are still decided by end_week with their pre-change reason codes.
  • S138.8 unknown_target is registered in SIMULATION_REASON_CODES with a strings-catalog message, and the reason-completeness guard passes. 10 §10's row reads dispatched.
  • S138.9 Every simulation fixture in src/engine/fixtures/replay/ (stable-life-, bulgaria-stable-life-, long-horizon-) replays to its recorded outcome unchanged.
  • S138.10 kindVersion stays 1.1.0. A save serialized before the change loads with replayCompatible intact and continues play. branchSession over a hand-built log whose retained prefix holds an unissued targetId throws SessionStoreError invalid_state with nothing written. Out of scope: Session capture (S139); checking amountCents, close or any value that is not an id; moving end_week's checks to plan.add; story-graph and world-graph, which already check ids against content or state.

S139 — A Host Captures a Played Session as a Replay Fixture​

Status: done Delivers: Whoever runs a hosted game can turn a session someone played into a regression test, without asking anyone to play it again and without the test learning who played it. The capture reproduces the game exactly, changes nothing about the session, and says plainly when a session can no longer be reproduced. Touches: src/engine/src/core/composition/types.ts, src/engine/src/core/session/store.ts, tests alongside, including one capture-then-run round trip per kind Depends on: S138 Acceptance:

  • S139.1 For a played story-graph, simulation and world-graph session S, captureSession(S, "name") returns a fixture that runReplayFixture runs with no rejected submission, and whose Outcome (07 §3) equals the one built from S's own state. One of the three has adopted a content epoch.
  • S139.2 The fixture's fields are exactly 04 §7.4's: name is the argument; config is campaignId and seed, plus kindProfileData only when the record's initialization carries it; campaignVersion is the starting epoch, not the current one, for a session that adopted; capturedUnder equals ENGINE_VERSION; submissions has one entry per log entry, in order, an action as { actionId, params } deep-equal to the logged entry and an adoption as { adopt: <to> }.
  • S139.3 Captured from a session created with a profileId and an audience, the fixture's JSON contains neither the profileId, the sessionId nor the audience, and no timestamp.
  • S139.4 Capture writes nothing: the persistence adapter records no write, no StateChange is emitted, and the record's revision is unchanged, on success and on each refusal in S139.6. Capturing a session the layer has not cached leaves it uncached: the next operation on it reads the adapter.
  • S139.5 Two captures of an unchanged session with the same name are deep-equal, under a clock and an IdSource that return a different value on every call.
  • S139.6 Each refusal is a SessionStoreError with operation: "captureSession" and nothing written: an unknown sessionId is unknown_session; a replayCompatible: false session, a log with a migration entry, a record that predates initialization with a profileId whose kind declares profileData, and a hand-built simulation log holding rest with a targetId are each invalid_state; a log whose starting or adopted epoch the archive no longer holds is unknown_campaign.
  • S139.7 A capture issued while a command on the same session is in flight returns a fixture that includes that command's entry.
  • S139.8 Every existing branchSession test passes unchanged, and its refusals still carry operation: "branchSession".
  • S139.9 captureSession is a member of SessionLayer and not of SessionStore, no MCP tool exposes it, and the client API-coverage checklist (09) still has thirteen rows with its build gate passing. Out of scope: When or why a host captures, consent, where a fixture is stored and for how long, promotion to the committed corpus and its golden Outcome (08 §7), a capture that records the submission a session faulted on, and wiring capture into SubZeroDev.Adventures.

Landed​

Ordered by id. Criteria is the range of numbered acceptance criteria; units sliced before criteria were numbered show a dash. Merge is the merge commit of the last pull request listed.

SliceNamePull requestsCriteriaMerge
W0CI and Documentation Gates#3, #6—98ccee9
W1Core Contract Types and Module Skeleton#17—f7d8f59
W2RNG Handle and Stream Derivation#22—9f90eae
W3Pure Engine Kernel#33—7a46e57
W3aObservability: Emitter, Events, and Sinks#34—4567e43
W4Registry, Authoring Builder, Localization#35—a7e9dc3
W5Tiered Validation#36—71ff9e8
W6Projection#37—b4d2af9
W7Session Store#38—10b4ce9
W8Profile Store#39—d9af5d8
W9Variables and Consequences#41—9359a12
W10Conditions and Requirements#43—388f2dd
W11Nodes, Turn, and Settle#44—6d85917
W12Scene, Actions, Projection, Reason Codes#47—9eb400a
W13Endings and Achievements#51—0fb7cff
W14Story-Graph Validation#55—231683f
W15The Bureaucracy Campaign and Broken Fixtures#60—75fc64a
W16Text Client#63—5d6e418
W17MCP Server#66—74d81b7
W18Determinism Harness#70—bf106d6
W19MVP Acceptance#71—dcb7803
W20Engine Versioning and Release Tags#73—e26fa9d
W21Replay Oracle: Outcome and the Runner#73—e26fa9d
W22Replay Oracle: The Corpus#73—e26fa9d
W23Replay Oracle: CI Wiring#73—e26fa9d
W24Core Spec Reconciliation#77—f203325
W25Simulation Kind Seam Reconciliation#80—399391b
W26Toolchain Upgrade#82—0d3cbb1
W27Bulgaria Adventure: The Driving Arc#86—25a9411
W28Bulgaria Adventure: The Return Arc#87—c186f4d
W29Bulgaria Adventure: The Inheritance Arc#88—265a1ca
W30Bulgaria Adventure: The Enterprise Arc#89—d8d8821
W31Save Migration#92—588567d
W32Simulation Kind: State Types#94—fc1b29b
W33Simulation Kind: Actor State#95—2f0b7ea
W34Simulation Kind: Content Definition Types#96—9f157cb
W35Simulation Kind: Resolution and Systems#97—f1d7a21
W36Simulation Kind: State, Variables, and the Plan#98—4c80b05
W37Simulation Kind: The Week#99—0815702
W38Simulation Kind: Content-Definition Types#100—bdd07d6
W39Simulation Kind: Wiring the "Stable Life" Vertical Slice#101—1206daf
W40Simulation Kind: The "Stable Life" Scenario, Validation, and Corpus#102—9fdf77c
W41Engine Consumer Boundary#108—db9c62a
W42World-Graph Runtime State Contract#116—5f5f8f5
W43World-Graph Content Definition Contract#119, #121—4ab8b0a
W44World-Graph Resolution Contract#120—5eca57b
W45World-Graph Kind Skeleton and Immediate Actions#125—c6662bb
W46World-Graph Deterministic Tick Pipeline#128—6301a49
W47World-Graph MVP Vertical Slice#131—2390750
W48Preview/Client Parity#133—c81227d
W49World-Graph Validation, Scenario and Replay Guard#134, #138—6e3d38e
W50Simulation Kind: Projection and Client Parity#166W50.1–W50.8bb97138
W51Simulation Kind: Derived Values, Modifiers, and Effects#194, #204W51.1–W51.66b573d3
W52Simulation Kind: The Scenario Campaign and Full Validation#227W52.1–W52.6a9b7307
W53Simulation Kind: Employment and Income#228W53.1–W53.64034606
W54Simulation Kind: Education and Skills#230W54.1–W54.6bd163f9
W55Simulation Kind: Housing, Debt, and Reconciliation#232W55.1–W55.61e63ce5
W56Simulation Kind: Possessions, Places, and People#236W56.1–W56.6e20cfdb
W57Simulation Kind: Events, Opportunities, Headlines, and Achievements#265W57.1–W57.79d69704
W58Content Pack Resolution and Content Identity#238W58.1–W58.81dda794
W59Experiment Gates and the ExperimentSource Port#239W59.1–W59.6fffe42e
W60A Second Locale, End to End#240W60.1–W60.5214a6cc
W61Public Playable Web Demo#183, #184W61.1–W61.10cd75c37
W62Platform Static Host Image#264W62.1–W62.96013c69
W63Absurd Game Interface#188, #190W63.1–W63.10a94da4b
W64Replayable Story Campaign Expansion#189, #191W64.1–W64.13c1db016
W65Browser Test Harness for the Site#201W65.1–W65.88e9378d
W66The Play Surface on a Phone#205W66.1–W66.12a115098
W67Restore the Story-Graph Regression Evidence#261W67.1–W67.7ce90041
W68Make the Browser Save Adapter Actually RestoreCancelled — #271W68.1–W68.7—
W69Consume the Reusable Landing-Page Package#272W69.1–W69.838b7c34
W70Gate /play/'s Startup Request SurfaceCancelled — #271W70.1–W70.6—
W71The Bulgaria Culture Pack: Mechanism and Voice#314W71.1–W71.6f0d1fee
W72The Bulgaria Culture Pack: The Full Setting#317W72.1–W72.566075df
W73Tier 3 Validation as an Author-Facing Check#319W73.1–W73.61bdb5b6
W74Campaign Content Ownership: The Authoring Seam#298, #299, #332W74.1–W74.8cbbe0cd
W74aThe Bureaucracy Fixture, Frozen Byte-for-Byte#333W74a.1–W74a.57cb09a0
W74bRetire the Engine's Own Play Surface#335W74b.1–W74b.65c26ab0
W74cThe Breaking Ownership Release#336W74c.1–W74c.73fee866
W75Classified Persistence Conflicts#311W75.1–W75.76f5adb4
W76A Folded Registry Keeps Its Resolution#337W76.1–W76.10b1cd9be
W77Tier 1 and Tier 2 as an Author-Facing Check#338W77.1–W77.76dd7b4f
W78Localization Coverage and String Extraction#340W78.1–W78.677e35c7
W79What Changed Between Two Resolutions#342W79.1–W79.6b6f8bb2
W80Seeing a Story Graph#343W80.1–W80.61ba588e
W81Construction Finishes What build Starts#345W81.1–W81.10b589b6c
W82A Kiosk That Ran Out Can Be Refilled#347W82.1–W82.8aa56f77
W83Buildings Get Dirty, Wear Out, and Break#348W83.1–W83.704c1baf
W84Incidents That Happen On Their Own#360W84.1–W84.98fdded3
W85The Resort Tells You What Needs Attention#361W85.1–W85.103ea878e
W86The Story-Graph Kind Says Why#362W86.1–W86.7450935c
W87The World-Graph Kind Says What Its Ticks Did#363W87.1–W87.724641e0
W88The Simulation Kind Nobody Outside the Package Can Use#367W88.1–W88.68115478
W89A Game-Length Life#376W89.1–W89.871e7364
W90Canonical and Public Truth#377—274fa6e
W91Reproducible Package and Release Gate#379—63a0c12
W92Local and CI Gate Health#378—fab72b1
W93World-Graph Spatial and Audit Correctness#398W93.1–W93.6f0735fc
W94Simulation Resolution Correctness#400W94.1–W94.7e103de6
W95Effect and Audit Semantics#403, #404W95.1–W95.76c1fb4f
W96Mechanical Regression Boundaries#408W96.1–W96.7bb62ffe
W97Immutable Projections and Shared Pipelines#409W97.1–W97.6b9ecdc2
W98Catalog and Projection Completeness#414W98.1–W98.7d7ca8eb
W99Session Lifecycle Operations#416W99.1–W99.851ad81a
W100Campaign-Tunable Weekly Rules#419W100.1–W100.73d496b3
W101Projects, Businesses, and Rival Scarcity#421W101.1–W101.8b595fe2
W102Profile Chains and Simulation Save Migration#423W102.1–W102.86e4a7bb
W103Companion Contracts and Ownership Reconciliation#424, #427, #451W103.1–W103.8e52e7a1
W104Release 0.11 Compatibility Sweep#447, #448W104.1–W104.79ece598
W105Documentation and Landing Publication Review#455W105.1–W105.79439b99
W106Tracker Evidence Closure#456W106.1–W106.7865f827
W1070.11 Release Candidate Verification#460, #461W107.1–W107.7a84d9c9
W108Publish 0.11 Readiness#471, #474, #475W108.1–W108.6651a2a4
W109A Uniform That Makes Its Wearer More Employable#478W109.1–W109.6a2850f4
W110An NPC Who Already Remembers You#479W110.1–W110.485f59c5
W111Conditions That Can Ask "Do You Own One?"#481W111.1–W111.6ef8abff
W112A Car That Costs Money to Run#490W112.1–W112.6a431ed2
W113Utilities and Transport on the Weekly Bill#491W113.1–W113.7aadb482
W114The NPCs Are Actually There#495W114.1–W114.57d8850d
W115A Resort a Client Can Actually Draw#518W115.1–W115.118e13420
W116A Campaign Cannot Ask About a Field Its Collection Lacks#538W116.1–W116.87fc2237
W117Two Campaigns, One Lever, Different Weeks#542W117.1–W117.719442c4
W118The Week's Own Rules Belong to the Campaign#544W118.1–W118.7c187ee6
W119Every Fixed Action Price Is the Campaign's#548W119.1–W119.75435b74
W120One Campaign Can Include Another#568W120.1–W120.107081402