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 underplans/. Each row's anchor is the unit's id in lower case, so a link ending in#w90still 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.mdand05-observability.md. The register of unknowns isOPEN-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 workloadprocess 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,
demandBandthresholds 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-templatehardening findings, to raise upstream, from the automated review of PR #3: the docs workflows pin the mutable:latesttag (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.mdandgames/bulgaria-adventure.mdin 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
submitActionandpreviewActionreturn onlyStateChangeandOutcomeMessagerecords whosevisibleistrue, on accept and on reject. The whole serialized result contains no hidden record's value. - S121.2 The gate applies to every audience,
aiincluded. - S121.3 The profile fold still reads the unfiltered result: an achievement unlocked by a
visible: falsechange is recorded on the profile, and is absent from the returned changes. - S121.4 The MCP
chooseandpreview_actiontools 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
ActionResultis unchanged and still carries every record. Out of scope: Filtering inside the engine,SceneorPlayerView(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.revisionis0whencreateSession,loadGameorbranchSessionfirst writes a record, and rises by one on each acceptedsubmitActionwrite. 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
previewActionleaves 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_failurerestores the cached record and keeps it, without re-reading persistence. Every field the refusedputcarried 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, neverattemptCounter. Out of scope: Adventures' migration (arevisioncolumn, 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.putthrows,saveGameraisesstorage_failure, andlistSaveson the same instance does not include the refused save. - S123.2
loadGameof the refused save's id raisesunknown_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
saveGameis durable, listed, and loadable from a fresh instance. - S123.5
createSession,loadGameandbranchSessionalready 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
paramsobject aftersubmitActionorpreviewActionleaves the returned state'sserialize()output and itsactionLogunchanged. - S124.2
Kind.advancereceives the same copy theLoggedActionrecords, 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
deserializerejects a state whosekindIdis registered but is not its campaign'skindId, withinvalid_state(path: "kindId"), and emits onecore.deserialize.rejectedwithreason: "invalid_state". - S125.2
migraterejects 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
campaignVersionis still not compared on the raw path. That gap is recorded in90-decisions.md, Content epochs, and is not fixed here. Out of scope: AnycampaignVersionpolicy (D3, content epochs); kind-ownedkindStatevalidation (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
Kindgains a requiredvalidateState(kindState, campaign): boolean.deserializecalls it after its own checks. Afalseor a throw rejects withinvalid_state(path: "kindState") and emits onecore.deserialize.rejected;migrateinherits it. - S126.2
resolveSaveEnvelopeasks the kind after both migrations. A refusal isinvalid_statefor an unmigrated save andmigration_failedfor a migrated one. - S126.3 Story-graph checks every field's type, that
currentNodeIdnames a campaign node, and that every declared variable is present with itsVarType; 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 in90-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.sessionCacheLimitbounds the session cache, least recently used first, whenpersistenceis supplied. Omitted, the cache is unbounded. The limit is refused when it is not a positive integer, or whenpersistenceis 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,deleteSaveandlistSavesread the adapter every time. Out of scope: Cache, session and blob metrics (recorded in90-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.jsondeclares"license": "MIT", and the packed archive containspackage/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 CIenginejob inspects its tarball with that guard rather than an inline copy of its rules. - S128.3 The
enginejob's change detector diffs every input it reads:src/engine,consumer-smoke,LICENSEand its own workflow. A consumer-smoke-only change runs it. - S128.4
agent.mdpoints atnpm testfor 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
EngineHostgainsarchive?: ResolutionArchive. Without one, the engine uses an archive holding onlyregistry.Engine.contentexposes whichever archive is in use.createEnginethrows when the archive does not resolve every campaign ofregistryat its registered version. - S129.2
scene,view,availableActions,submitAction,previewAction,deserializeandmigratetakeKindContext.registryandKindContext.campaignfromcontent.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 ascampaignVersion. A version that does not resolve fails withunknown_campaign. - S129.4
deserializeandmigratereject a state whose(campaignId, campaignVersion)does not resolve withunknown_campaignand onecore.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
ResolutionArchiveis 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
actionLogelement type isLoggedEntry, a union ofLoggedAction,LoggedContentandLoggedMigration. The starting version is derived: the first epoch entry'sfrom, elsecampaignVersion. An epoch entry consumes aseq, 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_incompatiblefor both a refusal and a throw, andinvalid_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 }, setscampaignVersiontotoandformatVersionto2, and emits onecore.content.adoptedwithfromVersionandtoVersion. A refusal emits onecore.content.pinnedwithreason.toequal to the state's own version returns the state unchanged.AdoptionResulthas no changes or messages, and no stream is derived for an adoption (C24). - S130.4
deserializerejects withinvalid_stateaformatVersion: 1state whose log carries an epoch entry, a state whose last epoch entry'stois not itscampaignVersion, and aformatVersionabove2. It accepts a version-2state that satisfies C26, and does not resolve a migration entry'sfrom. 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 throughadoptContent, which must adopt. A fixture crossing an epoch, built on a test-only kind that declaresadoptContent, 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
Kindgains the optionaladoptContent, andcontent_kind_changed,content_not_adoptableandcontent_incompatiblejoin the base reason codes with their message keys.LoggedEntry,LoggedContent,LoggedMigration,AdoptionResultandAdoptDecisionare exported from the package root. Out of scope: Any shipped kind'sadoptContent(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 ofinitialat its version. It throws wheninitialfails validation. - S131.2
publishre-runs every campaign'svalidateCampaignagainstregistry.strings. Any error refuses the publication with those errors; nothing is stored andlatest()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 itsEpochRef. - S131.4 What the archive serves is a deep-frozen copy: mutating the registry after
publishreturns changes nothingresolveanswers.migrateStateis kept by reference. - S131.5
latest()is the last publication that succeeded.current(scope)answers that publication's version ofscope.campaignIdfor any session or profile, andundefinedfor a campaign it does not hold. - S131.6 An engine whose
archiveis 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_conflictjoins the base reason codes.createContentArchive,ContentArchive,ContentChannel,ContentScopeandEpochRefare 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_incompatibleeach of the following, one test per case: acurrentNodeIdthe 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; avisitedCountskey that names no target node; an unlocked achievement the target lacks; a carried variable whose targetVarTypediffers; and a variable the source declared and the state carries that the target does not declare. - S132.3 Availability is judged by the same
showWhenand 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
validateStateagainst 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
SessionHostgainscontent?: ContentChannel.createSessionasks it once and passes the answer tocreateGame. 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 toadoptContent, 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 withstorage_failureorconcurrent_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
previewActionor a rejected action. A recording channel shows zero calls across them. With no channel, every existing session-store test passes unchanged. - S133.6
getStringsreturns 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 atloadGameandbranchSession(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.contentresolves, and whosekindVersionis the registered kind's, loads on that epoch with no migration and keepsreplayCompatible.loadGamethen 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
campaignVersionon a log that carries an epoch entry appends{ seq, system: "migration", from, to }and keepsformatVersion: 2, so the result passesdeserialize(C26). A migration on a log with no epoch entry appends nothing and keepsformatVersion: 1. A migration that changes onlykindVersionappends nothing. Migrating the migrated save again appends no second entry. - S134.3
branchSessionreplays the retained prefix from the starting version: actions throughsubmitAction, content entries throughadoptContent, and then reaches its own adoption point.atActionCountcounts entries of every type. A fork before an adoption starts on the old epoch. - S134.4
branchSessionrefuses with nothing written:invalid_statefor a log carrying a migration entry,invalid_statefor a recorded adoption that now refuses, andunknown_campaignfor 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 ofSacross a content entry. Out of scope: Capture refusing areplayCompatible: falsesession (capture is unbuilt; recorded in90-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
SubmissionisActionSubmission | AdoptionSubmission, andDecisionisActionDecision | AdoptionDecision. Every existing fixture and recorded outcome reads, and matches, unchanged. - S135.2 The runner resolves the fixture's
campaignVersionand every adopted version through the engine'scontentbefore running.campaign_withdrawnstill means the campaign id is gone;campaign_version_missingnow means any version the fixture names does not resolve. It creates the game withcreateGame(config, fixture.campaignVersion). - S135.3 An adoption runs through
adoptContent. A refusal records anAdoptionDecisionwithaccepted: falseand its reason, and the verdict isdivergedat that index. An adoption counts indecisionsand never inacceptedActions. - 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.addwith{ actionType: "borrow_money", amountCents: 5000, choiceId: "x" }returnsunexpected_params/simulation.reason.unexpected_params, appends nothing to the action log, and leavesserialize(state)byte-identical. - S136.2 A key declared only for another
actionTypeis rejected:{ actionType: "operate_business", amountCents: 100 }returnsunexpected_params. - S136.3 Every declared shape in 10 §4 is accepted with its pre-change outcome: each of
borrow_money,repay_debt,deposit_savingsandinvestwithamountCents;respond_to_eventwithchoiceId;operate_businesswithclose; and any type with or withouttargetId. The storedGameAction.parametersholds only declared keys. - S136.4
plan.removewith{ index: 0, extra: 1 }, andplan.clearorend_weekwith any non-emptyparams, returnunexpected_paramsand append nothing. - S136.5
unexpected_paramsis registered inSIMULATION_REASON_CODESwith 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
kindVersionstays1.1.0. A save serialized before the change loads withreplayCompatibleintact and continues play.90-decisions.mdrecords why there is no bump: the rule changesadvance, not state shape, and only a log that already carries an undeclared key is affected. - S136.8
branchSessionover a log whose retained prefix holds an action the kind now rejects throwsSessionStoreErrorinvalid_statewith nothing written, as it does for a refused adoption, and no longer throws a plainError. 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: CheckingtargetIdandchoiceIdagainst content atplan.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
buildwith{ definitionId, x, y, rotation, note: "hi" }returnsunexpected_params/world-graph.reason.unexpected_params, appends nothing, and leavesserialize(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_staffaccepts its optionalzoneIdandbuildingIdpresent or absent. - S137.3 The key check runs before any value is read:
advance_tickswith{ ticks: "bad", extra: 1 }returnsunexpected_params, notunknown_action. - S137.4
unexpected_paramsis registered inWORLD_GRAPH_REASON_CODESwith 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
kindVersionstays1.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.addwith{ actionType: "apply_for_job", targetId: "job-never-issued" }returnsunknown_target/simulation.reason.unknown_target, appends nothing to the action log, and leavesserialize(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.addnaming an id outside it returnsunknown_target, and one naming an id inside it is accepted with its pre-change outcome. - S138.3
respond_to_eventwhosetargetIdnames no pending response returnsunknown_target; one naming a pending response with achoiceIdoutside itsavailableChoiceIdsreturnsunknown_target; one with achoiceIdinside 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
shopis added asaction-<n>,sell_itemwithtargetId: "inv-action-<n>"is accepted; after thatshopis removed byplan.remove, or the plan is emptied byplan.clear, the samesell_itemreturnsunknown_target. The same holds forstart_project→work_on_project(project-action-<n>) andstart_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— acceptstargetId:{ actionType: "rest", targetId: "loc-1" }returnsunexpected_params. The existing test that assertsreststores atargetIdasserts the rejection instead. - S138.6 The check runs last:
{ actionType: "teleport" }returnsunknown_action;{ actionType: "apply_for_job", targetId: "job-never-issued", amountCents: 1 }returnsunexpected_params; and while an event response is pending,{ actionType: "apply_for_job", targetId: "job-never-issued" }returnsevent_response_pending. - S138.7 What
plan.adddoes not check is unchanged:{ actionType: "apply_for_job" }with notargetIdis still accepted, and affordability, requirements, active enrollment and plan order are still decided byend_weekwith their pre-change reason codes. - S138.8
unknown_targetis registered inSIMULATION_REASON_CODESwith 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
kindVersionstays1.1.0. A save serialized before the change loads withreplayCompatibleintact and continues play.branchSessionover a hand-built log whose retained prefix holds an unissuedtargetIdthrowsSessionStoreErrorinvalid_statewith nothing written. Out of scope: Session capture (S139); checkingamountCents,closeor any value that is not an id; movingend_week's checks toplan.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 thatrunReplayFixtureruns with no rejected submission, and whoseOutcome(07 §3) equals the one built fromS's own state. One of the three has adopted a content epoch. - S139.2 The fixture's fields are exactly 04 §7.4's:
nameis the argument;configiscampaignIdandseed, pluskindProfileDataonly when the record'sinitializationcarries it;campaignVersionis the starting epoch, not the current one, for a session that adopted;capturedUnderequalsENGINE_VERSION;submissionshas 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
profileIdand anaudience, the fixture's JSON contains neither theprofileId, thesessionIdnor theaudience, and no timestamp. - S139.4 Capture writes nothing: the persistence adapter records no write, no
StateChangeis emitted, and the record'srevisionis 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
nameare deep-equal, under a clock and anIdSourcethat return a different value on every call. - S139.6 Each refusal is a
SessionStoreErrorwithoperation: "captureSession"and nothing written: an unknownsessionIdisunknown_session; areplayCompatible: falsesession, a log with a migration entry, a record that predatesinitializationwith aprofileIdwhose kind declaresprofileData, and a hand-built simulation log holdingrestwith atargetIdare eachinvalid_state; a log whose starting or adopted epoch the archive no longer holds isunknown_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
branchSessiontest passes unchanged, and its refusals still carryoperation: "branchSession". - S139.9
captureSessionis a member ofSessionLayerand not ofSessionStore, 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 goldenOutcome(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.
| Slice | Name | Pull requests | Criteria | Merge |
|---|---|---|---|---|
| W0 | CI and Documentation Gates | #3, #6 | — | 98ccee9 |
| W1 | Core Contract Types and Module Skeleton | #17 | — | f7d8f59 |
| W2 | RNG Handle and Stream Derivation | #22 | — | 9f90eae |
| W3 | Pure Engine Kernel | #33 | — | 7a46e57 |
| W3a | Observability: Emitter, Events, and Sinks | #34 | — | 4567e43 |
| W4 | Registry, Authoring Builder, Localization | #35 | — | a7e9dc3 |
| W5 | Tiered Validation | #36 | — | 71ff9e8 |
| W6 | Projection | #37 | — | b4d2af9 |
| W7 | Session Store | #38 | — | 10b4ce9 |
| W8 | Profile Store | #39 | — | d9af5d8 |
| W9 | Variables and Consequences | #41 | — | 9359a12 |
| W10 | Conditions and Requirements | #43 | — | 388f2dd |
| W11 | Nodes, Turn, and Settle | #44 | — | 6d85917 |
| W12 | Scene, Actions, Projection, Reason Codes | #47 | — | 9eb400a |
| W13 | Endings and Achievements | #51 | — | 0fb7cff |
| W14 | Story-Graph Validation | #55 | — | 231683f |
| W15 | The Bureaucracy Campaign and Broken Fixtures | #60 | — | 75fc64a |
| W16 | Text Client | #63 | — | 5d6e418 |
| W17 | MCP Server | #66 | — | 74d81b7 |
| W18 | Determinism Harness | #70 | — | bf106d6 |
| W19 | MVP Acceptance | #71 | — | dcb7803 |
| W20 | Engine Versioning and Release Tags | #73 | — | e26fa9d |
| W21 | Replay Oracle: Outcome and the Runner | #73 | — | e26fa9d |
| W22 | Replay Oracle: The Corpus | #73 | — | e26fa9d |
| W23 | Replay Oracle: CI Wiring | #73 | — | e26fa9d |
| W24 | Core Spec Reconciliation | #77 | — | f203325 |
| W25 | Simulation Kind Seam Reconciliation | #80 | — | 399391b |
| W26 | Toolchain Upgrade | #82 | — | 0d3cbb1 |
| W27 | Bulgaria Adventure: The Driving Arc | #86 | — | 25a9411 |
| W28 | Bulgaria Adventure: The Return Arc | #87 | — | c186f4d |
| W29 | Bulgaria Adventure: The Inheritance Arc | #88 | — | 265a1ca |
| W30 | Bulgaria Adventure: The Enterprise Arc | #89 | — | d8d8821 |
| W31 | Save Migration | #92 | — | 588567d |
| W32 | Simulation Kind: State Types | #94 | — | fc1b29b |
| W33 | Simulation Kind: Actor State | #95 | — | 2f0b7ea |
| W34 | Simulation Kind: Content Definition Types | #96 | — | 9f157cb |
| W35 | Simulation Kind: Resolution and Systems | #97 | — | f1d7a21 |
| W36 | Simulation Kind: State, Variables, and the Plan | #98 | — | 4c80b05 |
| W37 | Simulation Kind: The Week | #99 | — | 0815702 |
| W38 | Simulation Kind: Content-Definition Types | #100 | — | bdd07d6 |
| W39 | Simulation Kind: Wiring the "Stable Life" Vertical Slice | #101 | — | 1206daf |
| W40 | Simulation Kind: The "Stable Life" Scenario, Validation, and Corpus | #102 | — | 9fdf77c |
| W41 | Engine Consumer Boundary | #108 | — | db9c62a |
| W42 | World-Graph Runtime State Contract | #116 | — | 5f5f8f5 |
| W43 | World-Graph Content Definition Contract | #119, #121 | — | 4ab8b0a |
| W44 | World-Graph Resolution Contract | #120 | — | 5eca57b |
| W45 | World-Graph Kind Skeleton and Immediate Actions | #125 | — | c6662bb |
| W46 | World-Graph Deterministic Tick Pipeline | #128 | — | 6301a49 |
| W47 | World-Graph MVP Vertical Slice | #131 | — | 2390750 |
| W48 | Preview/Client Parity | #133 | — | c81227d |
| W49 | World-Graph Validation, Scenario and Replay Guard | #134, #138 | — | 6e3d38e |
| W50 | Simulation Kind: Projection and Client Parity | #166 | W50.1–W50.8 | bb97138 |
| W51 | Simulation Kind: Derived Values, Modifiers, and Effects | #194, #204 | W51.1–W51.6 | 6b573d3 |
| W52 | Simulation Kind: The Scenario Campaign and Full Validation | #227 | W52.1–W52.6 | a9b7307 |
| W53 | Simulation Kind: Employment and Income | #228 | W53.1–W53.6 | 4034606 |
| W54 | Simulation Kind: Education and Skills | #230 | W54.1–W54.6 | bd163f9 |
| W55 | Simulation Kind: Housing, Debt, and Reconciliation | #232 | W55.1–W55.6 | 1e63ce5 |
| W56 | Simulation Kind: Possessions, Places, and People | #236 | W56.1–W56.6 | e20cfdb |
| W57 | Simulation Kind: Events, Opportunities, Headlines, and Achievements | #265 | W57.1–W57.7 | 9d69704 |
| W58 | Content Pack Resolution and Content Identity | #238 | W58.1–W58.8 | 1dda794 |
| W59 | Experiment Gates and the ExperimentSource Port | #239 | W59.1–W59.6 | fffe42e |
| W60 | A Second Locale, End to End | #240 | W60.1–W60.5 | 214a6cc |
| W61 | Public Playable Web Demo | #183, #184 | W61.1–W61.10 | cd75c37 |
| W62 | Platform Static Host Image | #264 | W62.1–W62.9 | 6013c69 |
| W63 | Absurd Game Interface | #188, #190 | W63.1–W63.10 | a94da4b |
| W64 | Replayable Story Campaign Expansion | #189, #191 | W64.1–W64.13 | c1db016 |
| W65 | Browser Test Harness for the Site | #201 | W65.1–W65.8 | 8e9378d |
| W66 | The Play Surface on a Phone | #205 | W66.1–W66.12 | a115098 |
| W67 | Restore the Story-Graph Regression Evidence | #261 | W67.1–W67.7 | ce90041 |
| W68 | Make the Browser Save Adapter Actually Restore | Cancelled — #271 | W68.1–W68.7 | — |
| W69 | Consume the Reusable Landing-Page Package | #272 | W69.1–W69.8 | 38b7c34 |
| W70 | Gate /play/'s Startup Request Surface | Cancelled — #271 | W70.1–W70.6 | — |
| W71 | The Bulgaria Culture Pack: Mechanism and Voice | #314 | W71.1–W71.6 | f0d1fee |
| W72 | The Bulgaria Culture Pack: The Full Setting | #317 | W72.1–W72.5 | 66075df |
| W73 | Tier 3 Validation as an Author-Facing Check | #319 | W73.1–W73.6 | 1bdb5b6 |
| W74 | Campaign Content Ownership: The Authoring Seam | #298, #299, #332 | W74.1–W74.8 | cbbe0cd |
| W74a | The Bureaucracy Fixture, Frozen Byte-for-Byte | #333 | W74a.1–W74a.5 | 7cb09a0 |
| W74b | Retire the Engine's Own Play Surface | #335 | W74b.1–W74b.6 | 5c26ab0 |
| W74c | The Breaking Ownership Release | #336 | W74c.1–W74c.7 | 3fee866 |
| W75 | Classified Persistence Conflicts | #311 | W75.1–W75.7 | 6f5adb4 |
| W76 | A Folded Registry Keeps Its Resolution | #337 | W76.1–W76.10 | b1cd9be |
| W77 | Tier 1 and Tier 2 as an Author-Facing Check | #338 | W77.1–W77.7 | 6dd7b4f |
| W78 | Localization Coverage and String Extraction | #340 | W78.1–W78.6 | 77e35c7 |
| W79 | What Changed Between Two Resolutions | #342 | W79.1–W79.6 | b6f8bb2 |
| W80 | Seeing a Story Graph | #343 | W80.1–W80.6 | 1ba588e |
| W81 | Construction Finishes What build Starts | #345 | W81.1–W81.10 | b589b6c |
| W82 | A Kiosk That Ran Out Can Be Refilled | #347 | W82.1–W82.8 | aa56f77 |
| W83 | Buildings Get Dirty, Wear Out, and Break | #348 | W83.1–W83.7 | 04c1baf |
| W84 | Incidents That Happen On Their Own | #360 | W84.1–W84.9 | 8fdded3 |
| W85 | The Resort Tells You What Needs Attention | #361 | W85.1–W85.10 | 3ea878e |
| W86 | The Story-Graph Kind Says Why | #362 | W86.1–W86.7 | 450935c |
| W87 | The World-Graph Kind Says What Its Ticks Did | #363 | W87.1–W87.7 | 24641e0 |
| W88 | The Simulation Kind Nobody Outside the Package Can Use | #367 | W88.1–W88.6 | 8115478 |
| W89 | A Game-Length Life | #376 | W89.1–W89.8 | 71e7364 |
| W90 | Canonical and Public Truth | #377 | — | 274fa6e |
| W91 | Reproducible Package and Release Gate | #379 | — | 63a0c12 |
| W92 | Local and CI Gate Health | #378 | — | fab72b1 |
| W93 | World-Graph Spatial and Audit Correctness | #398 | W93.1–W93.6 | f0735fc |
| W94 | Simulation Resolution Correctness | #400 | W94.1–W94.7 | e103de6 |
| W95 | Effect and Audit Semantics | #403, #404 | W95.1–W95.7 | 6c1fb4f |
| W96 | Mechanical Regression Boundaries | #408 | W96.1–W96.7 | bb62ffe |
| W97 | Immutable Projections and Shared Pipelines | #409 | W97.1–W97.6 | b9ecdc2 |
| W98 | Catalog and Projection Completeness | #414 | W98.1–W98.7 | d7ca8eb |
| W99 | Session Lifecycle Operations | #416 | W99.1–W99.8 | 51ad81a |
| W100 | Campaign-Tunable Weekly Rules | #419 | W100.1–W100.7 | 3d496b3 |
| W101 | Projects, Businesses, and Rival Scarcity | #421 | W101.1–W101.8 | b595fe2 |
| W102 | Profile Chains and Simulation Save Migration | #423 | W102.1–W102.8 | 6e4a7bb |
| W103 | Companion Contracts and Ownership Reconciliation | #424, #427, #451 | W103.1–W103.8 | e52e7a1 |
| W104 | Release 0.11 Compatibility Sweep | #447, #448 | W104.1–W104.7 | 9ece598 |
| W105 | Documentation and Landing Publication Review | #455 | W105.1–W105.7 | 9439b99 |
| W106 | Tracker Evidence Closure | #456 | W106.1–W106.7 | 865f827 |
| W107 | 0.11 Release Candidate Verification | #460, #461 | W107.1–W107.7 | a84d9c9 |
| W108 | Publish 0.11 Readiness | #471, #474, #475 | W108.1–W108.6 | 651a2a4 |
| W109 | A Uniform That Makes Its Wearer More Employable | #478 | W109.1–W109.6 | a2850f4 |
| W110 | An NPC Who Already Remembers You | #479 | W110.1–W110.4 | 85f59c5 |
| W111 | Conditions That Can Ask "Do You Own One?" | #481 | W111.1–W111.6 | ef8abff |
| W112 | A Car That Costs Money to Run | #490 | W112.1–W112.6 | a431ed2 |
| W113 | Utilities and Transport on the Weekly Bill | #491 | W113.1–W113.7 | aadb482 |
| W114 | The NPCs Are Actually There | #495 | W114.1–W114.5 | 7d8850d |
| W115 | A Resort a Client Can Actually Draw | #518 | W115.1–W115.11 | 8e13420 |
| W116 | A Campaign Cannot Ask About a Field Its Collection Lacks | #538 | W116.1–W116.8 | 7fc2237 |
| W117 | Two Campaigns, One Lever, Different Weeks | #542 | W117.1–W117.7 | 19442c4 |
| W118 | The Week's Own Rules Belong to the Campaign | #544 | W118.1–W118.7 | c187ee6 |
| W119 | Every Fixed Action Price Is the Campaign's | #548 | W119.1–W119.7 | 5435b74 |
| W120 | One Campaign Can Include Another | #568 | W120.1–W120.10 | 7081402 |