From 43d17e38d2b42c8fd4acc018cd5dc4373b129dd6 Mon Sep 17 00:00:00 2001 From: Ben Richards Date: Thu, 8 Oct 2026 21:11:21 +0300 Subject: [PATCH 1/3] =?UTF-8?q?Content=20epochs=20Revision=202=20=E2=80=94?= =?UTF-8?q?=20resolve=20red-team=20F1=E2=80=93F3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit F1: a migration that changes campaignVersion appends a LoggedMigration entry, so a migrated content-bearing save stays inside C26; the entry ends replay (branch, capture, harness refuse it). F2: story-graph adoption requires the current node to stay a ChoiceNode with an available choice, since adoption runs no settle. F3: getStrings returns the union of the session's live epochs, newest winning per key (new C27), rather than a Scene epoch stamp plus a getStrings version parameter. --- design/10-design.md | 152 ++++++++++++++++++++---- design/16-content-epochs.md | 6 +- design/20-contract.md | 107 ++++++++++++----- design/90-decisions.md | 23 +++- docs/docs/engine/03-story-graph-kind.md | 11 +- docs/docs/engine/04-core.md | 77 ++++++++---- docs/docs/engine/08-session-capture.md | 3 +- docs/docs/engine/09-clients.md | 4 +- docs/docs/engine/16-content-epochs.md | 145 ++++++++++++++++++---- docs/docs/engine/OPEN-QUESTIONS.md | 5 +- docs/docs/guide.md | 19 +-- 11 files changed, 442 insertions(+), 110 deletions(-) diff --git a/design/10-design.md b/design/10-design.md index 726e6504..81fe8e3c 100644 --- a/design/10-design.md +++ b/design/10-design.md @@ -2225,7 +2225,8 @@ with no translation step and can be promoted into the corpus without conversion A session that adopted new content (16) is captured with an `AdoptionSubmission` (07 §2) at each content entry's position. That is not a new format — the union is 07's — and an adoption carries no caller input to filter: its only value is a version the store chose from the host's -channel, never one a client sent. +channel, never one a client sent. A session whose log carries a migration entry (16 §3.5) has no +fixture form — a migration is not a step replay can perform — and is not captured. --- @@ -2438,7 +2439,9 @@ client would have fallen into: restricted to this surface had no way to resolve `labelKey`, `titleKey` or an `OutcomeMessage` — the contract as first written could not be implemented (04 §7). The catalog carries its own table for the same reason one layer earlier: `titleKey` has no - session to resolve against (04 §7.3). + session to resolve against (04 §7.3). Under content epochs the table spans every epoch the + session has run on and only grows (16 §5.7), so a key a client already holds never stops + resolving, and a key its cached table lacks means *fetch the table again*. - **`submitAction` returns `SessionActionResult`, not `ActionResult`.** The latter's success value is the *envelope* — seed, action log, opaque `kindState` — so returning it would put raw state past the projection boundary and make §6 a convention rather than a guarantee. @@ -4006,7 +4009,7 @@ sidebar_label: Content Epochs # Content Epochs — Live Content Without a Reload -**Document status:** Revision 1 — new contract, post-MVP +**Document status:** Revision 2 — red-team findings F1–F3 resolved (`90-decisions.md`, 2026-10-08), post-MVP **Reading order:** after [`11-content-packs.md`](11-content-packs.md), whose `ResolutionId` this treats as an epoch, [`04-core.md`](04-core.md) §2, §4 and §7, which it amends, and @@ -4061,33 +4064,43 @@ them: | **Adoption** | Moving one session from its epoch to another, recorded at one log position. | | **Pinned** | A session that stays on its epoch, because adoption was refused or never offered. | | **Adoption point** | One of the four places the session store asks the channel and may adopt (§5.3). | +| **Epoch entry** | A log entry that changes `campaignVersion`: a content entry (an adoption) or a migration entry (a migrated load, §3.5). | --- ## 3. Data Model -### 3.1 The log carries content entries +### 3.1 The log carries epoch entries The envelope gains no field. The element type of `actionLog` widens from `LoggedAction` to a -union, and the new member records an adoption: +union. One new member records an adoption; the other records a migrated load (§3.5): ```typescript -type LoggedEntry = LoggedAction | LoggedContent; +type LoggedEntry = LoggedAction | LoggedContent | LoggedMigration; interface LoggedContent { seq: number; // consumes a seq, exactly like an action system: "content"; from: string; // the campaignVersion before adoption - to: string; // the campaignVersion after; equals GameState.campaignVersion until the next one + to: string; // the campaignVersion after; equals GameState.campaignVersion until the next epoch entry +} + +interface LoggedMigration { + seq: number; // consumes a seq, exactly like an action + system: "migration"; + from: string; // the campaignVersion the save was made under + to: string; // the campaignVersion the migration restamped } ``` -`GameState.campaignVersion` remains the one place a state's current epoch lives. A content -entry records the transition, not the state, so the two never disagree: the last entry's `to` -*is* `campaignVersion`, and an engine check enforces it on `deserialize`. +`GameState.campaignVersion` remains the one place a state's current epoch lives. An epoch +entry records the transition, not the state, so the two never disagree: the last epoch entry's +`to` *is* `campaignVersion`, and an engine check enforces it on `deserialize`. **Every change of +`campaignVersion` leaves one** — an adoption a content entry, a migrated load a migration entry +— so the log is the complete history of the epochs a game has run on. **The replay input becomes `{ seed, starting version, actionLog }`, and the starting version -is derived, not stored.** It is the first content entry's `from`, or `campaignVersion` when the +is derived, not stored.** It is the first epoch entry's `from`, or `campaignVersion` when the log has none. A stored `startingCampaignVersion` field would have duplicated what the log already says — the sixth entry in the envelope-duplication ledger, avoided rather than recorded. @@ -4104,12 +4117,13 @@ unreproducible. ### 3.3 Format version -`GameState.formatVersion` moves from `1` to `2` **only on a state whose log carries a content -entry.** `adoptContent` writes `2` when it appends one; a game that never adopts keeps `1` and -serializes byte-identically to today. The reader accepts both. An engine predating this design +`GameState.formatVersion` moves from `1` to `2` **only on a state whose log carries an epoch +entry.** `adoptContent` writes `2` when it appends a content entry and a migrated load when it +appends a migration entry; a game that never adopts and never migrates across a campaign version +keeps `1` and serializes byte-identically to today. The reader accepts both. An engine predating this design rejects a version-`2` state with `invalid_state`, which is the correct answer — it would read -a content entry as an action with no `actionId` — and still reads every state that never -adopted, which contains nothing it cannot understand. +an epoch entry as an action with no `actionId` — and still reads every state that carries none, +which contains nothing it cannot understand. > **Why not bump unconditionally.** Every golden file and every stored session would change > for a feature none of them use, and an older engine would refuse states it reads perfectly @@ -4130,6 +4144,38 @@ Strings are part of an epoch because a session renders through the string table a publication that changes a string without changing a version is the silent drift content-addressing exists to refuse. +### 3.5 A migrated load leaves a migration entry + +04 §10.2's migration path restamps `campaignVersion` to the loading campaign's own. Before this +revision it left the log untouched, and on a log that already carried a content entry that broke +C26: a save at `e2` whose last content entry ends at `e2`, migrated onto `e3`, would be a state +whose `campaignVersion` and last `to` disagree — refused `invalid_state` by the next `deserialize` +of the session it had just loaded into (red-team F1). + +So **a migration that changes `campaignVersion` appends a `LoggedMigration { from, to }`**, in +the same step that restamps it and sets `replayCompatible: false`, and stamps `formatVersion: 2`. +It does so whether or not the log already carried content entries: one rule — every change of +`campaignVersion` is logged — keeps C26 universal and keeps the derived starting version +truthful, where a rule that logged only some migrations would leave a migrated log naming a +starting epoch it never ran on. A migration that changes only `kindVersion` restamps no +version and appends nothing. + +**A migration entry is not replayable, and the log says so.** A migration is not a step replay +can perform — the epoch it left is, by construction, one this host could not resolve — so: + +- **The record and the log agree.** A state whose log carries a migration entry is exactly a + state whose lineage passed through a migrated version change, which is what + `replayCompatible: false` already records on the save and the session record. The log now + says it too, so the state alone is enough to know. +- **`branchSession` refuses** such a session, as it already refuses `replayCompatible: false`. +- **Capture has no submission for one** (08 §2), so no fixture carries one. +- **Only the epochs after the last migration entry are live.** Retention (§6) and the + session's string table (§5.7) count the log from there: what came before is history the + session can no longer replay, and nothing resolves through it. + +A migration remains idempotent against its own output (04 §10.2): the second load finds no +version mismatch, runs no migration, and appends no second entry. + --- ## 4. Module Boundaries @@ -4288,7 +4334,9 @@ kind-version change, which is a save-load concern, and a running engine has one The story-graph kind adopts additive change and refuses anything that could strand the player. After any campaign migration, it adopts iff: -- `currentNodeId` names a node the target has; +- `currentNodeId` names a node the target has, **that node is a `ChoiceNode`, and at least + one of its choices is available** — `showWhen` true and requirements met — against the + adopted state, after the insertion below; - every `visitedCounts` key and every `unlockedAchievements` id exists in the target; - every variable the state carries that the target declares has the target's `VarType`; - no variable the source declared and the state carries is missing from the target. @@ -4296,6 +4344,17 @@ player. After any campaign migration, it adopts iff: Variables the target declares and the state lacks are inserted at their declared initial value. Everything else passes through unchanged. A refusal reasons `content_incompatible`. +**The node the player stands on must still be a place to act.** An active story-graph session +always rests on a choice node, because `submitChoice` and `createGame` both settle until they +reach one (`20-contract.md`, story-graph §8.2). Adoption runs no settle — settling would draw +randomness and walk nodes, which is play, and C24 forbids it — so a target that turned the +current node into an ending, an auto or a random node, or gated away every choice it offers, +would leave an active scene with nothing to submit: every choice rejects `not_a_choice_node` or +its gate, and an ending the target means is never recorded (red-team F2). Such a target is +refused and the session stays pinned on content where it can still play. An author who means to +end a session in place does so through content the player reaches, not by rewriting the node +under them. + That last condition settles what `validateState` deferred (`20-contract.md`, story-graph §8.1): a save whose campaign later dropped a variable is still tolerated *at load*, because the save is the record and refusing it strands the player; *adoption* refuses the same silent drop, because @@ -4305,15 +4364,54 @@ drop a variable writes a `migrateState` that drops it. ### 5.6 Replay and branching Replay re-runs a log from its starting version. For each entry: an action goes through -`submitAction`; a content entry goes through `adoptContent(state, entry.to)` and must adopt. A +`submitAction`; a content entry goes through `adoptContent(state, entry.to)` and must adopt; a +migration entry is never reached, because no replayable log carries one (§3.5). A content entry that now refuses is a divergence — the kind's compatibility rule changed, which is precisely what a cross-version oracle exists to catch (07 §6). The C1 harness (04 §14) treats both entry types the same way: byte-identical serialization after every entry. `branchSession` replays the retained prefix the same way, then reaches its own adoption point. -`atActionCount` counts log entries of both types, so a fork may fall either side of an adoption; +`atActionCount` counts log entries of every type, so a fork may fall either side of an adoption; a branch taken before one starts on the old epoch and is offered the new one on return. +### 5.7 Strings across an adoption + +A client renders keys from one call with a table from another: `getScene`, then `getStrings`. +Between the two, another client's `resumeSession` may adopt, and if the new epoch renamed a key +the scene carries, a table drawn from the new epoch alone cannot resolve it. Reading the table +first permits the inverse. Every call respects the session lock; the race is between completed +calls, so the lock cannot close it (red-team F3). + +**So a session's string table spans every epoch the session has run on, and only grows.** +`getStrings(sessionId)` returns the union of the string tables of the session's live epochs — +its starting version, or the last migration entry's `to` when the log carries one (§3.5), and +every content entry's `to` after it — each key resolved from the most recent of those epochs +that defines it. The current epoch therefore wins every key it has, and a key it dropped still +resolves through the epoch that last had it. A session that never adopted gets exactly the table +it always did. + +That makes one property hold, and gives a client one rule: + +- **Any key in any result the store returned for a session resolves in every later + `getStrings` of that session.** Every such key came from an epoch the log named when the + result was built, and within one session id the log only grows (C27). +- **A client that meets a key its cached table lacks fetches the table again**, and the key is + then present. The mismatch the red team found is therefore detectable, and recoverable with + the surface a client already has. + +It also covers the case the race only illustrates: a `submitAction` that adopts after commit +returns messages built on the old epoch beside a scene projected from the new one (§5.3), and +one table resolves both. + +**What it does not promise** is the wording a key had when the result was built. A key both +epochs define resolves to the newer text, so an older scene can show newer wording for a key the +new epoch reworded. That is accepted rather than designed away: the new wording is the content +the host published, and a client that needs the exact old text is asking for the old epoch, +which `getStrings` is not the API for. + +The union adds nothing to retention (§6): every epoch it spans is already named by the session's +log, so retention already keeps it. + --- ## 6. Distribution @@ -4335,7 +4433,8 @@ engine requires of a multi-instance host is narrow: epoch and the packs once. How it does so is host-owned. **Retention.** An epoch may be dropped only when no live session, no save and no retained -fixture names it — as a starting version or as any content entry's `from` or `to`. Dropping one +fixture names it — as a starting version or as any content entry's `from` or `to`, counting, in a +log that carries a migration entry, only from the last one's `to` (§3.5). Dropping one earlier turns those into `unknown_campaign` at the next load and `campaign_version_missing` at the next replay. The engine cannot see saves or fixtures, so it cannot enforce this; the in-memory archive sidesteps it by never evicting. @@ -4351,14 +4450,17 @@ in-memory archive sidesteps it by never evicting. | `createEngine` | `archive` lacks a registry campaign at its registered version | Throws — a construction error | | `createGame` | The requested version does not resolve | `unknown_campaign` | | `deserialize` / `migrate` | `(campaignId, campaignVersion)` does not resolve | `unknown_campaign`, emitting `core.deserialize.rejected` — closes the retained gap | -| `deserialize` | Last content entry's `to` ≠ `campaignVersion`, or a content entry in a version-`1` state | `invalid_state` | +| `deserialize` | Last epoch entry's `to` ≠ `campaignVersion`, or an epoch entry in a version-`1` state | `invalid_state` | | Adoption | Target missing; kind changed; seam absent; migration failed; kind refused; result invalid; session ended | Pinned, with the §5.4 reason; the command completes | | Adoption point | Channel throws | No offer; the command completes | | Adoption point | The adoption-only write at `resumeSession` fails (`storage_failure`, `concurrent_modification`) | The store restores; `resumeSession` returns the unadopted scene, and the next adoption point retries | | `loadGame` | The save's epoch is archived and its `kindVersion` matches | Loads on that epoch with no migration — `replayCompatible` kept — then adopts | -| `loadGame` | Otherwise | 04 §10.2's migration path, unchanged | +| `loadGame` | Otherwise | 04 §10.2's migration path; one that changes `campaignVersion` appends a migration entry and stamps `formatVersion: 2` (§3.5) | | `branchSession` | A version the retained prefix names does not resolve | `unknown_campaign` (the existing row, widened) | | `branchSession` | A recorded content entry now refuses | `invalid_state`; nothing written | +| `branchSession` | The log carries a migration entry | `invalid_state` — the existing `replayCompatible: false` refusal, now readable from the log | +| Adoption, story-graph | The target's node at `currentNodeId` is not a choice node, or offers no available choice | Pinned `content_incompatible` (§5.5) | +| `getStrings` | A key an earlier result carried is absent from the current epoch | Resolved through the most recent live epoch that defines it (§5.7) | | Replay | The starting or any recorded version does not resolve | `unrunnable: campaign_version_missing` | | Replay | A recorded adoption now refuses | `diverged` at that index | @@ -4401,6 +4503,12 @@ in-memory archive sidesteps it by never evicting. | **Reject a version mismatch on `deserialize`** | Turned down in plan 53 (D3): it ends exactly the sessions injection exists for. Resolving the version through the archive replaces the gap rather than closing it by refusal. | | **Bump `formatVersion` unconditionally** | §3.3. | | **Rename `campaign_version_missing` to a new verdict** | The type is public and three suites switch on it; widening what triggers it costs nothing. | +| **Exempt migrated states from C26** (F1) | `replayCompatible` lives on the save and the session record, not on `GameState`, so `deserialize` cannot see an exemption; and an unchecked last `to` is the integrity check removed rather than satisfied. | +| **Log a migration only when the log already carries content entries** (F1) | Satisfies C26 but leaves a migrated log with no content entries deriving a starting epoch it never ran on; one rule for every version change is the smaller contract. | +| **Reuse `LoggedContent` for a migration** (F1) | Replay would read it as an adoption and try to repeat it; a migration's source epoch is one the host could not resolve, so the attempt fails as a divergence that is really a category error. | +| **Settle the story-graph state at adoption** (F2) | Settle walks nodes and may draw randomness — play, which C24 forbids adoption to invent. | +| **Require only that the node's type is unchanged** (F2) | Misses a choice node whose every choice the target gated away — the same stranding by a different edit. | +| **Stamp each `Scene` with its epoch and let `getStrings` take one** (F3) | Exact, but a new field on `Scene`, a new parameter on a store operation and its MCP tool, and still two epochs in one `SessionActionResult` when `submitAction` adopts after commit. The union answers both with no surface change. | --- diff --git a/design/16-content-epochs.md b/design/16-content-epochs.md index 61d260cc..cc50d9b9 100644 --- a/design/16-content-epochs.md +++ b/design/16-content-epochs.md @@ -10,7 +10,7 @@ ## 3. Data Model -### 3.1 The log carries content entries +### 3.1 The log carries epoch entries ### 3.2 Sequence numbers and randomness @@ -18,6 +18,8 @@ ### 3.4 The archive +### 3.5 A migrated load leaves a migration entry + ## 4. Module Boundaries ### 4.1 The engine resolves by state @@ -40,6 +42,8 @@ ### 5.6 Replay and branching +### 5.7 Strings across an adoption + ## 6. Distribution ## 7. Failure Modes diff --git a/design/20-contract.md b/design/20-contract.md index 4886c63f..c5197d29 100644 --- a/design/20-contract.md +++ b/design/20-contract.md @@ -47,8 +47,9 @@ below is the one this repository has already watched fail five times. review.* - **C3.** `{ seed, actionLog }` is the complete replay input. No generator state is persisted; every stream is a pure function of `(seed, streamId)` (§8). The content it replays against is - named by the envelope, not supplied beside it: the starting epoch is the first content entry's - `from`, else `campaignVersion` (`10-design.md`, Content Epochs §3.1). *Enforced by the type — `GameState` declares no RNG + named by the envelope, not supplied beside it: the starting epoch is the first epoch entry's + `from`, else `campaignVersion` (`10-design.md`, Content Epochs §3.1). A log carrying a + migration entry is not a replay input (§10.2). *Enforced by the type — `GameState` declares no RNG field, and adding one is a contract amendment.* - **C4.** Removing every event changes nothing: a fixture replayed with `nullEmitter` and with `createRecordingEmitter()` yields byte-identical `serialize()` output, an identical @@ -131,7 +132,7 @@ defeat it (§9.1). runs both entry types.* - **C22.** A pinned session's content never changes. Every state-taking operation resolves content by the state's own `(campaignId, campaignVersion)`, and `campaignVersion` changes only - through `adoptContent` or a migrated load. *Enforced by code — resolution by state, over an + through `adoptContent` or a migrated load, each of which appends an epoch entry (§2). *Enforced by code — resolution by state, over an archive in which one key names one content (C23).* - **C23.** The archive serves only content that passed `validateCampaign`, and a key, once held, names the same content forever. *Enforced by code for the in-memory archive — `publish` @@ -143,9 +144,15 @@ defeat it (§9.1). instruction for the kind's half.* - **C25.** A session adopts only at the four adoption points (§7), never on a query and never on a rejected action, and a refusal never fails the command it rides on. *Enforced by code.* -- **C26.** A state's last content entry's `to` equals its `campaignVersion`, and a state is - `formatVersion: 2` exactly when its log carries a content entry. *Enforced by code — - `deserialize` rejects either disagreement with `invalid_state`.* +- **C26.** A state's last epoch entry's `to` — a content entry's or a migration entry's — + equals its `campaignVersion`, and a state is `formatVersion: 2` exactly when its log carries an + epoch entry. *Enforced by code — `deserialize` rejects either disagreement with + `invalid_state`, and §10.2's migration appends the entry that keeps a migrated state inside + it.* +- **C27.** A session's string table never loses a key: any `LocKey` in any result the store + returned for a session resolves in every later `getStrings` of that session (§7). *Enforced + by code — the table is the union over the session's live epochs, and within one session id + the log only grows.* ## Unresolved @@ -280,7 +287,7 @@ what `advance`, `serialize`, and the session store operate on. type KindId = "story-graph" | "simulation" | "world-graph"; interface GameState { - formatVersion: number; // the shape of THIS envelope — 1, or 2 once the log carries a content entry (§10.2) + formatVersion: number; // the shape of THIS envelope — 1, or 2 once the log carries an epoch entry (§10.2) gameId: string; // from the IdSource port (06 §5.1); opaque to the core kindId: KindId; @@ -297,10 +304,10 @@ interface GameState { type GameStatus = "active" | "ended" | "abandoned"; -type LoggedEntry = LoggedAction | LoggedContent; +type LoggedEntry = LoggedAction | LoggedContent | LoggedMigration; interface LoggedAction { - seq: number; // 0-based, monotonic across both entry types + seq: number; // 0-based, monotonic across every entry type actionId: string; // the action the player submitted params?: Readonly>; } @@ -309,17 +316,27 @@ interface LoggedContent { // an adoption (16 §3.1) seq: number; // consumes a seq, exactly like an action system: "content"; from: string; // the campaignVersion before adoption - to: string; // the campaignVersion after — the last entry's `to` IS campaignVersion + to: string; // the campaignVersion after — the last epoch entry's `to` IS campaignVersion +} + +interface LoggedMigration { // a migrated load that changed campaignVersion (§10.2; 16 §3.5) + seq: number; // consumes a seq, exactly like an action + system: "migration"; + from: string; // the campaignVersion the save was made under + to: string; // the campaignVersion the migration restamped } ``` **The log records adoptions, and the starting epoch is derived from it.** A game that adopts new content mid-play (§4, `adoptContent`; [`16-content-epochs.md`](16-content-epochs.md)) appends a `LoggedContent` entry, so the replay input is -`{ seed, starting version, actionLog }`, where the starting version is the first content +`{ seed, starting version, actionLog }`, where the starting version is the first epoch entry's `from`, or `campaignVersion` when the log carries none. It is not stored: a -`startingCampaignVersion` field would duplicate what the log already says. `formatVersion` is `2` -exactly on a state whose log carries a content entry, and `1` otherwise — a game that never -adopts serializes byte-identically to one written before content epochs existed (C26). +`startingCampaignVersion` field would duplicate what the log already says. **Every change of +`campaignVersion` is an epoch entry** — an adoption a `LoggedContent`, a migrated load a +`LoggedMigration` (§10.2) — so the last epoch entry's `to` is always the current version. +`formatVersion` is `2` exactly on a state whose log carries an epoch entry, and `1` otherwise — +a game that never adopts and never migrates across a campaign version serializes +byte-identically to one written before content epochs existed (C26). **What lives here vs in `kindState`.** The envelope holds everything a game has *regardless of kind*: identity, campaign reference, seed, status, and the action @@ -783,7 +800,7 @@ missing kind gets. With no archive supplied, every behaviour below is unchanged. ```text submitAction(state, actionId, params): - 1. kind = kinds[state.kindId]; seq = state.actionLog.length // 0-based, monotonic; counts content entries too + 1. kind = kinds[state.kindId]; seq = state.actionLog.length // 0-based, monotonic; counts epoch entries too registry = content.resolve(state.campaignId, state.campaignVersion) // by state, above campaign = registry.campaigns[state.campaignId] 2. handle = rngHandleFor(state.seed, { kind:"action", seq }) // §8 — derived, not carried @@ -831,12 +848,13 @@ Without it, a state could hand one kind's `advance` another kind's campaign; wit a `kindState` of the wrong shape reaches `advance` and fails there, as a throw or a `NaN`, rather than at the boundary. -**`campaignVersion` resolves through the archive, and a log's content entries must agree with +**`campaignVersion` resolves through the archive, and a log's epoch entries must agree with it.** `unknown_campaign` also covers a `campaignId` the archive holds at no epoch matching `campaignVersion`: a state is played against the content it names or not at all, never silently against whatever this host registered under the id (C22). Two `invalid_state` refusals guard the -log: a `formatVersion: 1` state whose log carries a content entry, and a state whose last content -entry's `to` is not its `campaignVersion` (C26). A `formatVersion` above `2` refuses as every +log: a `formatVersion: 1` state whose log carries an epoch entry, and a state whose last epoch +entry's `to` is not its `campaignVersion` (C26). A migration entry's `from` is not resolved: it +names an epoch this host could not run, which is why the migration happened. A `formatVersion` above `2` refuses as every unknown version does. This closes the gap an earlier revision recorded here as known and retained — a state serialized under one resolution and accepted under another (`90-decisions.md`, 2026-10-08). @@ -1067,8 +1085,9 @@ type StringTable = Readonly>; > `ValidationError` — and a client that may call nothing but this store (09 §2) otherwise has > no way to render any of them. Resolving them *inside* the DTOs was the alternative and is > worse: it would bake a locale into the projection and lose the property that clients never -> string-match English (§12). The table is keyed by the campaign and locale the session was -> created with; a locale switch is a new session, which is all the MVP's single locale needs. +> string-match English (§12). The table is keyed by the session's campaign, its live epochs +> (below, C27) and the locale the session was created with; a locale switch is a new session, +> which is all the MVP's single locale needs. **The store persists the envelope (§2) and nothing else about play.** Wall-clock timestamps, owner ids, and other host metadata live on the store's record, outside the @@ -1144,8 +1163,17 @@ to start on and passes it to `createGame(config, version)`; that is creation, no logs no content entry. With no channel, no session is ever offered content, and every operation above behaves as it did before content epochs existed. -`getStrings` and every projection resolve through the session's *current* epoch: after an -adoption, the table is the one the new content was published with. +Every projection resolves through the session's *current* epoch. **`getStrings` returns the +union of the string tables of the session's live epochs** — its starting version, or the last +migration entry's `to` when the log carries one, and every content entry's `to` after it — each +key resolved from the most recent of those epochs that defines it. The current epoch wins every +key it has; a key it dropped still resolves through the epoch that last had it; a session that +never adopted gets exactly its one epoch's table. So the table only grows, and any key a result +for this session carried resolves in every later `getStrings` (C27) — including a `submitAction` +that adopts after commit, whose messages come from the old epoch and whose scene from the new. +A client whose cached table lacks a key fetches it again. What the union does not promise is a +key's *earlier wording*: a key both epochs define resolves to the newer text +([`16-content-epochs.md`](16-content-epochs.md) §5.7). ### 7.1 The Profile Store @@ -1731,7 +1759,7 @@ creation and `actionLog.length` branches at the present. Valid range is `[0, act inclusive; anything outside it raises `invalid_fork_point` and writes nothing. A count is used rather than a `LoggedAction.seq` because the two coincide only while a log is gap-free, and a count states the intended meaning — *how much of this game* — without depending on that. -**It counts log entries of both types** (§2): a content entry is part of how much of this game +**It counts log entries of every type** (§2): a content entry is part of how much of this game there is, so a fork may fall either side of an adoption, and one taken before it starts on the old epoch. @@ -1790,7 +1818,9 @@ An anonymous session branches to an anonymous one. **A session that is not replay-compatible cannot be branched.** A `StoredSessionRecord` with `replayCompatible: false` has passed through a migrated load (§10.2), and its log is no longer guaranteed to regenerate its state — which is the entire mechanism a branch depends on. It -raises `invalid_state` and writes nothing. Failing here is the sticky-forward rule doing its +raises `invalid_state` and writes nothing. A source whose log carries a migration entry (§2) is +refused the same way; the two coincide for every version-changing migration, and the log entry +makes the refusal readable from the state alone. Failing here is the sticky-forward rule doing its job: the alternative is a branch that silently diverges from the game it claims to continue. #### Reproducing a stored session from its log @@ -1844,7 +1874,7 @@ already does. | `deleteSave` | `storage_failure` | the adapter threw | Yes | retry; nothing was removed | | `branchSession` | `unknown_session` | no such `sessionId` | No | the session is gone | | `branchSession` | `invalid_fork_point` | `atActionCount` outside `[0, actionLog.length]` | No | **new** — correct the count | -| `branchSession` | `invalid_state` | the source is `replayCompatible: false` | No | branching this lineage is impossible, not delayed | +| `branchSession` | `invalid_state` | the source is `replayCompatible: false`, or its log carries a migration entry | No | branching this lineage is impossible, not delayed | | `branchSession` | `invalid_state` | a content entry in the retained prefix no longer adopts | No | the kind's compatibility rule changed under a recorded adoption — 07 §6's divergence, one layer down | | `branchSession` | `unknown_campaign` | an epoch the retained prefix plays on — its starting version or one it adopted — no longer resolves | No | the content was withdrawn (07 §6's `unrunnable`, one layer down) | @@ -2219,11 +2249,22 @@ load the same way: - **A successful migration** sets `replayCompatible: false`, sticky forward — once a lineage has passed through a migrated load, it never becomes replay-compatible again, even across further saves that need no further migration. +- **A migration that changes `campaignVersion` logs it.** In the same step that restamps the + version, it appends `LoggedMigration { seq: actionLog.length, system: "migration", from, to }` + and stamps `formatVersion: 2` (§2). Without the entry, a save whose log already carried a + content entry would migrate into a state C26 refuses — its last `to` naming the epoch it left + ([`16-content-epochs.md`](16-content-epochs.md) §3.5; red-team F1). The entry is appended for + every version-changing migration, not only those over a content-bearing log, so the log is + always the complete history of the versions a game ran on. A migration that changes only + `kindVersion` restamps no version and appends nothing. A migration entry is never replayed: + `branchSession` refuses a log carrying one (§7.4), capture records none, and §14's runner + fails a fixture that carries one. - **A migration is idempotent against its own output.** Migrating a save, saving it, and migrating again — which is what happens whenever a player loads, plays nothing, and saves, or whenever a fixture is regenerated — must reach a canonically identical `kindState`. The first pass restamps `campaignVersion` to the campaign's own (`resolveSaveEnvelope`), so the - second pass finds no mismatch and runs no migration at all; that is *why* it holds, and + second pass finds no mismatch, runs no migration and appends no second migration entry; that + is *why* it holds, and stating the property rather than the mechanism is what makes it testable against a migration that mutates in place, accumulates, or appends. `replayCompatible` stays `false` through both, which is the sticky-forward rule above doing its work. @@ -2653,7 +2694,8 @@ interface PlaythroughFixture { The starting version is derived from the log exactly as §2 states, and a fixture whose log crosses an epoch needs an engine whose archive resolves every epoch it names. A content entry -that refuses on replay fails the fixture, the same as an action that rejects. +that refuses on replay fails the fixture, the same as an action that rejects. A migration entry +fails it outright: it is not a step replay can perform (§10.2). - **Golden files** — committed fixtures with expected `serialize()` output; a one-byte diff catches an unintended behaviour change across the whole engine. @@ -3384,13 +3426,18 @@ tolerated *at load*: nothing reads it, and the save is the record — refusing i player on a campaign that later dropped a variable. **`adoptContent` (04 §3) adopts additive change and refuses anything that could strand the -player.** After the target campaign's `migrateState`, it adopts iff `currentNodeId` names a node -the target has; every `visitedCounts` key names a target node and every `unlockedAchievements` +player.** After the target campaign's `migrateState`, it adopts iff `currentNodeId` names a +target node that is a `ChoiceNode` with at least one choice available — `showWhen` true and +requirements met (§4) — against the state with the insertion below applied; every +`visitedCounts` key names a target node and every `unlockedAchievements` id a target achievement; every variable the state carries that the target declares has the target's `VarType`; and no variable the source declared and the state carries is missing from the target. It returns the state with every variable the target declares and the state lacks inserted at its declared initial value, and everything else unchanged. Any other case refuses -`content_incompatible`. Adoption refuses the silent drop that load tolerates because there the +`content_incompatible`. The node condition exists because adoption runs no settle (§8.2) — +settling is play, which C24 forbids it — so a target that turned the current node into an +ending, auto or random node, or gated away every choice, would leave an active session with +nothing it can submit (red-team F2). Adoption refuses the silent drop that load tolerates because there the old epoch is still playable and staying pinned costs nothing; an author who means to drop a variable writes a `migrateState` that drops it ([`16-content-epochs.md`](16-content-epochs.md) §5.5). diff --git a/design/90-decisions.md b/design/90-decisions.md index 4e49ee6f..870777d5 100644 --- a/design/90-decisions.md +++ b/design/90-decisions.md @@ -421,8 +421,9 @@ injection exists for. The design is [`16-content-epochs.md`](16-content-epochs.md): an adoption is a logged `LoggedContent` entry, sessions adopt at four adoption points or stay pinned, content resolves by the state's own version through a content-addressed archive, and the kind judges through `Kind.adoptContent?`. -The contract is `20-contract.md` C21–C26, §2, §3, §4, §7, §10 and story-graph §8.1, and the -judgement calls are the 2026-10-08 entries in the decision log. Two refinements on the settled +The contract is `20-contract.md` C21–C27, §2, §3, §4, §7, §10 and story-graph §8.1, and the +judgement calls are the 2026-10-08 entries in the decision log, including the three that resolve +the red team's F1–F3 (`design/redteam/2026-10-08-10-design.md`). Two refinements on the settled directions: absent the seam, a session stays pinned rather than adopting additive change by default, and the replay oracle's new failure widens `campaign_version_missing` rather than replacing it. **Not yet built** — the next step is `/agentkit:plan`. @@ -2556,3 +2557,21 @@ Context: the 2026-10-03 review asked for a narrow story-extension contract or ve Chosen: no separate contract. Delivery is an epoch; authoring is an attachment (11 §3a, `20-contract.md` §10.4). What the merge does that attachments do not — adding achievements, more than one choice per host node — is registered as attachment gaps. Rejected: **A story-extension contract beside attachments** — two engine-owned shapes for adding content to a campaign, and the review itself warned against a merge framework without a consumer. Reversibility: a dedicated contract can still be designed if attachment gaps prove unclosable. + +### 2026-10-08 — A migrated load that changes `campaignVersion` logs a migration entry (red-team F1) +Context: red-team F1 (`design/redteam/2026-10-08-10-design.md`, BLOCKING). `20-contract.md` §10.2's fallback migration restamps `campaignVersion` and leaves `actionLog` untouched. A `formatVersion: 2` save whose last content entry ends at `e2`, loaded on a host that no longer archives `e2` and migrated onto `e3`, therefore became a state whose last `to` disagreed with its `campaignVersion`. C26 and `deserialize` refuse that as `invalid_state` on the next hydration or re-save. `replayCompatible: false` cannot exempt it, because that flag lives on the save and the session record, not on `GameState`. +Chosen: a new log member, `LoggedMigration { seq, system: "migration", from, to }`. A migration that changes `campaignVersion` appends one in the same step that restamps the version, and stamps `formatVersion: 2`. "Epoch entry" names a content entry or a migration entry. C26 is restated over epoch entries, C22 says every version change appends one, and the derived starting version is the first epoch entry's `from`. The entry is appended for every version-changing migration, not only over a log that already carries content entries, so the log is always the complete history of the versions a game ran on. A kind-shape-only migration appends nothing. A migration entry is never replayed. `branchSession` refuses a log carrying one, which is the existing `replayCompatible: false` refusal made readable from the state. Capture records none, and the §14 runner fails a fixture carrying one. Retention and the session string table count the log only from the last migration entry's `to`. Idempotency is unchanged: a second load finds no mismatch and appends nothing. The design is `16-content-epochs.md` §3.5; the contract is `20-contract.md` C3, C22, C26, §2, §4 `deserialize`, §7.4 and §10.2. +Rejected: **Exempting migrated states from C26.** `deserialize` cannot see the exemption, and the integrity check would be removed rather than satisfied. **Appending only when the log already carries content entries.** It satisfies C26 but leaves a migrated log without content entries deriving a starting epoch it never ran on. **Reusing `LoggedContent`.** Replay would treat the entry as an adoption and try to repeat a move whose source epoch the host could not resolve. +Reversibility: design only until content epochs are built. `src/engine/src/core/persistence/envelope.ts` still restamps without an entry, which is consistent today because no state yet carries a content entry; the entry lands with the content-epochs implementation. After that it is persisted in migrated saves, and every migrated save made from then on is `formatVersion: 2`, which an engine predating epochs refuses. That is a real cost to hosts that migrate but never adopt. + +### 2026-10-08 — Story-graph adoption requires the current node to stay a choice node with an available choice (red-team F2) +Context: red-team F2 (`design/redteam/2026-10-08-10-design.md`, LOCAL). Every story-graph adoption predicate checked that the referenced ids *exist*. A target that turned the current choice node into an ending node passed all of them, and so did `validateState`. Adoption runs no settle, so the session was left active with no available action: every choice rejected `not_a_choice_node`, and the ending was never recorded. +Chosen: the first predicate now also requires the target node at `currentNodeId` to be a `ChoiceNode` with at least one choice available (`showWhen` true, requirements met). That is evaluated against the adopted state after variable insertion and after any campaign migration. Anything else refuses `content_incompatible`, and the session stays pinned. The design is `16-content-epochs.md` §5.5; the contract is `20-contract.md` story-graph §8.1. +Rejected: **Settling at adoption.** Settle walks nodes and may draw randomness, which is play, and C24 forbids adoption to invent play. **Requiring only that the node kind is unchanged.** That misses a choice node whose every choice the target gated away, which strands the player the same way through a different edit. +Reversibility: tightening a predicate is cheap before build. After build, a relaxed rule would change which recorded adoptions replay, so a change is a 07 divergence and needs a decision. + +### 2026-10-08 — `getStrings` returns the union of a session's live epochs, newest winning per key (red-team F3) +Context: red-team F3 (`design/redteam/2026-10-08-10-design.md`, STRUCTURAL). A client calls `getScene` on `e1`, and then another client's `resumeSession` adopts `e2`, which renamed a label key. The first client's `getStrings` then returned only the `e2` table, so its scene carried a key it could not resolve. Neither `Scene` nor `StringTable` carries an epoch, so the client could not detect this. The race is between completed calls, so the session lock cannot close it. +Chosen: `getStrings(sessionId)` returns the union of the string tables of the session's live epochs. Those are the starting version, or the last migration entry's `to`, and every content entry's `to` after it. Each key resolves from the most recent of those epochs that defines it. New invariant C27: a session's table never loses a key, so any key in any result the store returned for a session resolves in every later `getStrings`. A client whose cached table lacks a key fetches the table again. The union also covers a `submitAction` that adopts after commit, whose messages come from the old epoch while its scene comes from the new one. Accepted residual: a key both epochs define resolves to the newer wording, even on an older scene. No type, signature or MCP schema changes, and the union spans only epochs the log already names, which retention already keeps. The design is `16-content-epochs.md` §5.7 and Clients §2; the contract is `20-contract.md` C27 and §7. +Rejected: **A `campaignVersion` on `Scene` plus `getStrings(sessionId, campaignVersion?)`.** It is exact, but it adds a field to `Scene`, a parameter to a store operation and its MCP tool, and still leaves two epochs in one `SessionActionResult` when `submitAction` adopts after commit. **Current epoch only, as Revision 1 had it.** That rule is the defect. +Reversibility: before build, one pull request. After build, the self-describing alternative stays available as an additive change. Narrowing the union back would break C27 for clients that rely on it. diff --git a/docs/docs/engine/03-story-graph-kind.md b/docs/docs/engine/03-story-graph-kind.md index 5fb6405c..ee6a210b 100644 --- a/docs/docs/engine/03-story-graph-kind.md +++ b/docs/docs/engine/03-story-graph-kind.md @@ -422,13 +422,18 @@ tolerated *at load*: nothing reads it, and the save is the record — refusing i player on a campaign that later dropped a variable. **`adoptContent` (04 §3) adopts additive change and refuses anything that could strand the -player.** After the target campaign's `migrateState`, it adopts iff `currentNodeId` names a node -the target has; every `visitedCounts` key names a target node and every `unlockedAchievements` +player.** After the target campaign's `migrateState`, it adopts iff `currentNodeId` names a +target node that is a `ChoiceNode` with at least one choice available — `showWhen` true and +requirements met (§4) — against the state with the insertion below applied; every +`visitedCounts` key names a target node and every `unlockedAchievements` id a target achievement; every variable the state carries that the target declares has the target's `VarType`; and no variable the source declared and the state carries is missing from the target. It returns the state with every variable the target declares and the state lacks inserted at its declared initial value, and everything else unchanged. Any other case refuses -`content_incompatible`. Adoption refuses the silent drop that load tolerates because there the +`content_incompatible`. The node condition exists because adoption runs no settle (§8.2) — +settling is play, which C24 forbids it — so a target that turned the current node into an +ending, auto or random node, or gated away every choice, would leave an active session with +nothing it can submit (red-team F2). Adoption refuses the silent drop that load tolerates because there the old epoch is still playable and staying pinned costs nothing; an author who means to drop a variable writes a `migrateState` that drops it ([`16-content-epochs.md`](16-content-epochs.md) §5.5). diff --git a/docs/docs/engine/04-core.md b/docs/docs/engine/04-core.md index e71f31c4..48a6b778 100644 --- a/docs/docs/engine/04-core.md +++ b/docs/docs/engine/04-core.md @@ -105,7 +105,7 @@ what `advance`, `serialize`, and the session store operate on. type KindId = "story-graph" | "simulation" | "world-graph"; interface GameState { - formatVersion: number; // the shape of THIS envelope — 1, or 2 once the log carries a content entry (§10.2) + formatVersion: number; // the shape of THIS envelope — 1, or 2 once the log carries an epoch entry (§10.2) gameId: string; // from the IdSource port (06 §5.1); opaque to the core kindId: KindId; @@ -122,10 +122,10 @@ interface GameState { type GameStatus = "active" | "ended" | "abandoned"; -type LoggedEntry = LoggedAction | LoggedContent; +type LoggedEntry = LoggedAction | LoggedContent | LoggedMigration; interface LoggedAction { - seq: number; // 0-based, monotonic across both entry types + seq: number; // 0-based, monotonic across every entry type actionId: string; // the action the player submitted params?: Readonly>; } @@ -134,17 +134,27 @@ interface LoggedContent { // an adoption (16 §3.1) seq: number; // consumes a seq, exactly like an action system: "content"; from: string; // the campaignVersion before adoption - to: string; // the campaignVersion after — the last entry's `to` IS campaignVersion + to: string; // the campaignVersion after — the last epoch entry's `to` IS campaignVersion +} + +interface LoggedMigration { // a migrated load that changed campaignVersion (§10.2; 16 §3.5) + seq: number; // consumes a seq, exactly like an action + system: "migration"; + from: string; // the campaignVersion the save was made under + to: string; // the campaignVersion the migration restamped } ``` **The log records adoptions, and the starting epoch is derived from it.** A game that adopts new content mid-play (§4, `adoptContent`; [`16-content-epochs.md`](16-content-epochs.md)) appends a `LoggedContent` entry, so the replay input is -`{ seed, starting version, actionLog }`, where the starting version is the first content +`{ seed, starting version, actionLog }`, where the starting version is the first epoch entry's `from`, or `campaignVersion` when the log carries none. It is not stored: a -`startingCampaignVersion` field would duplicate what the log already says. `formatVersion` is `2` -exactly on a state whose log carries a content entry, and `1` otherwise — a game that never -adopts serializes byte-identically to one written before content epochs existed (C26). +`startingCampaignVersion` field would duplicate what the log already says. **Every change of +`campaignVersion` is an epoch entry** — an adoption a `LoggedContent`, a migrated load a +`LoggedMigration` (§10.2) — so the last epoch entry's `to` is always the current version. +`formatVersion` is `2` exactly on a state whose log carries an epoch entry, and `1` otherwise — +a game that never adopts and never migrates across a campaign version serializes +byte-identically to one written before content epochs existed (C26). **What lives here vs in `kindState`.** The envelope holds everything a game has *regardless of kind*: identity, campaign reference, seed, status, and the action @@ -608,7 +618,7 @@ missing kind gets. With no archive supplied, every behaviour below is unchanged. ```text submitAction(state, actionId, params): - 1. kind = kinds[state.kindId]; seq = state.actionLog.length // 0-based, monotonic; counts content entries too + 1. kind = kinds[state.kindId]; seq = state.actionLog.length // 0-based, monotonic; counts epoch entries too registry = content.resolve(state.campaignId, state.campaignVersion) // by state, above campaign = registry.campaigns[state.campaignId] 2. handle = rngHandleFor(state.seed, { kind:"action", seq }) // §8 — derived, not carried @@ -656,12 +666,13 @@ Without it, a state could hand one kind's `advance` another kind's campaign; wit a `kindState` of the wrong shape reaches `advance` and fails there, as a throw or a `NaN`, rather than at the boundary. -**`campaignVersion` resolves through the archive, and a log's content entries must agree with +**`campaignVersion` resolves through the archive, and a log's epoch entries must agree with it.** `unknown_campaign` also covers a `campaignId` the archive holds at no epoch matching `campaignVersion`: a state is played against the content it names or not at all, never silently against whatever this host registered under the id (C22). Two `invalid_state` refusals guard the -log: a `formatVersion: 1` state whose log carries a content entry, and a state whose last content -entry's `to` is not its `campaignVersion` (C26). A `formatVersion` above `2` refuses as every +log: a `formatVersion: 1` state whose log carries an epoch entry, and a state whose last epoch +entry's `to` is not its `campaignVersion` (C26). A migration entry's `from` is not resolved: it +names an epoch this host could not run, which is why the migration happened. A `formatVersion` above `2` refuses as every unknown version does. This closes the gap an earlier revision recorded here as known and retained — a state serialized under one resolution and accepted under another (`90-decisions.md`, 2026-10-08). @@ -892,8 +903,9 @@ type StringTable = Readonly>; > `ValidationError` — and a client that may call nothing but this store (09 §2) otherwise has > no way to render any of them. Resolving them *inside* the DTOs was the alternative and is > worse: it would bake a locale into the projection and lose the property that clients never -> string-match English (§12). The table is keyed by the campaign and locale the session was -> created with; a locale switch is a new session, which is all the MVP's single locale needs. +> string-match English (§12). The table is keyed by the session's campaign, its live epochs +> (below, C27) and the locale the session was created with; a locale switch is a new session, +> which is all the MVP's single locale needs. **The store persists the envelope (§2) and nothing else about play.** Wall-clock timestamps, owner ids, and other host metadata live on the store's record, outside the @@ -969,8 +981,17 @@ to start on and passes it to `createGame(config, version)`; that is creation, no logs no content entry. With no channel, no session is ever offered content, and every operation above behaves as it did before content epochs existed. -`getStrings` and every projection resolve through the session's *current* epoch: after an -adoption, the table is the one the new content was published with. +Every projection resolves through the session's *current* epoch. **`getStrings` returns the +union of the string tables of the session's live epochs** — its starting version, or the last +migration entry's `to` when the log carries one, and every content entry's `to` after it — each +key resolved from the most recent of those epochs that defines it. The current epoch wins every +key it has; a key it dropped still resolves through the epoch that last had it; a session that +never adopted gets exactly its one epoch's table. So the table only grows, and any key a result +for this session carried resolves in every later `getStrings` (C27) — including a `submitAction` +that adopts after commit, whose messages come from the old epoch and whose scene from the new. +A client whose cached table lacks a key fetches it again. What the union does not promise is a +key's *earlier wording*: a key both epochs define resolves to the newer text +([`16-content-epochs.md`](16-content-epochs.md) §5.7). ### 7.1 The Profile Store @@ -1556,7 +1577,7 @@ creation and `actionLog.length` branches at the present. Valid range is `[0, act inclusive; anything outside it raises `invalid_fork_point` and writes nothing. A count is used rather than a `LoggedAction.seq` because the two coincide only while a log is gap-free, and a count states the intended meaning — *how much of this game* — without depending on that. -**It counts log entries of both types** (§2): a content entry is part of how much of this game +**It counts log entries of every type** (§2): a content entry is part of how much of this game there is, so a fork may fall either side of an adoption, and one taken before it starts on the old epoch. @@ -1615,7 +1636,9 @@ An anonymous session branches to an anonymous one. **A session that is not replay-compatible cannot be branched.** A `StoredSessionRecord` with `replayCompatible: false` has passed through a migrated load (§10.2), and its log is no longer guaranteed to regenerate its state — which is the entire mechanism a branch depends on. It -raises `invalid_state` and writes nothing. Failing here is the sticky-forward rule doing its +raises `invalid_state` and writes nothing. A source whose log carries a migration entry (§2) is +refused the same way; the two coincide for every version-changing migration, and the log entry +makes the refusal readable from the state alone. Failing here is the sticky-forward rule doing its job: the alternative is a branch that silently diverges from the game it claims to continue. #### Reproducing a stored session from its log @@ -1669,7 +1692,7 @@ already does. | `deleteSave` | `storage_failure` | the adapter threw | Yes | retry; nothing was removed | | `branchSession` | `unknown_session` | no such `sessionId` | No | the session is gone | | `branchSession` | `invalid_fork_point` | `atActionCount` outside `[0, actionLog.length]` | No | **new** — correct the count | -| `branchSession` | `invalid_state` | the source is `replayCompatible: false` | No | branching this lineage is impossible, not delayed | +| `branchSession` | `invalid_state` | the source is `replayCompatible: false`, or its log carries a migration entry | No | branching this lineage is impossible, not delayed | | `branchSession` | `invalid_state` | a content entry in the retained prefix no longer adopts | No | the kind's compatibility rule changed under a recorded adoption — 07 §6's divergence, one layer down | | `branchSession` | `unknown_campaign` | an epoch the retained prefix plays on — its starting version or one it adopted — no longer resolves | No | the content was withdrawn (07 §6's `unrunnable`, one layer down) | @@ -2044,11 +2067,22 @@ load the same way: - **A successful migration** sets `replayCompatible: false`, sticky forward — once a lineage has passed through a migrated load, it never becomes replay-compatible again, even across further saves that need no further migration. +- **A migration that changes `campaignVersion` logs it.** In the same step that restamps the + version, it appends `LoggedMigration { seq: actionLog.length, system: "migration", from, to }` + and stamps `formatVersion: 2` (§2). Without the entry, a save whose log already carried a + content entry would migrate into a state C26 refuses — its last `to` naming the epoch it left + ([`16-content-epochs.md`](16-content-epochs.md) §3.5; red-team F1). The entry is appended for + every version-changing migration, not only those over a content-bearing log, so the log is + always the complete history of the versions a game ran on. A migration that changes only + `kindVersion` restamps no version and appends nothing. A migration entry is never replayed: + `branchSession` refuses a log carrying one (§7.4), capture records none, and §14's runner + fails a fixture that carries one. - **A migration is idempotent against its own output.** Migrating a save, saving it, and migrating again — which is what happens whenever a player loads, plays nothing, and saves, or whenever a fixture is regenerated — must reach a canonically identical `kindState`. The first pass restamps `campaignVersion` to the campaign's own (`resolveSaveEnvelope`), so the - second pass finds no mismatch and runs no migration at all; that is *why* it holds, and + second pass finds no mismatch, runs no migration and appends no second migration entry; that + is *why* it holds, and stating the property rather than the mechanism is what makes it testable against a migration that mutates in place, accumulates, or appends. `replayCompatible` stays `false` through both, which is the sticky-forward rule above doing its work. @@ -2478,7 +2512,8 @@ interface PlaythroughFixture { The starting version is derived from the log exactly as §2 states, and a fixture whose log crosses an epoch needs an engine whose archive resolves every epoch it names. A content entry -that refuses on replay fails the fixture, the same as an action that rejects. +that refuses on replay fails the fixture, the same as an action that rejects. A migration entry +fails it outright: it is not a step replay can perform (§10.2). - **Golden files** — committed fixtures with expected `serialize()` output; a one-byte diff catches an unintended behaviour change across the whole engine. diff --git a/docs/docs/engine/08-session-capture.md b/docs/docs/engine/08-session-capture.md index b625fd7d..421063e3 100644 --- a/docs/docs/engine/08-session-capture.md +++ b/docs/docs/engine/08-session-capture.md @@ -63,7 +63,8 @@ with no translation step and can be promoted into the corpus without conversion A session that adopted new content (16) is captured with an `AdoptionSubmission` (07 §2) at each content entry's position. That is not a new format — the union is 07's — and an adoption carries no caller input to filter: its only value is a version the store chose from the host's -channel, never one a client sent. +channel, never one a client sent. A session whose log carries a migration entry (16 §3.5) has no +fixture form — a migration is not a step replay can perform — and is not captured. --- diff --git a/docs/docs/engine/09-clients.md b/docs/docs/engine/09-clients.md index fce8a89a..9adc54ae 100644 --- a/docs/docs/engine/09-clients.md +++ b/docs/docs/engine/09-clients.md @@ -78,7 +78,9 @@ client would have fallen into: restricted to this surface had no way to resolve `labelKey`, `titleKey` or an `OutcomeMessage` — the contract as first written could not be implemented (04 §7). The catalog carries its own table for the same reason one layer earlier: `titleKey` has no - session to resolve against (04 §7.3). + session to resolve against (04 §7.3). Under content epochs the table spans every epoch the + session has run on and only grows (16 §5.7), so a key a client already holds never stops + resolving, and a key its cached table lacks means *fetch the table again*. - **`submitAction` returns `SessionActionResult`, not `ActionResult`.** The latter's success value is the *envelope* — seed, action log, opaque `kindState` — so returning it would put raw state past the projection boundary and make §6 a convention rather than a guarantee. diff --git a/docs/docs/engine/16-content-epochs.md b/docs/docs/engine/16-content-epochs.md index 598addf9..728e94da 100644 --- a/docs/docs/engine/16-content-epochs.md +++ b/docs/docs/engine/16-content-epochs.md @@ -6,7 +6,7 @@ sidebar_label: Content Epochs -**Document status:** Revision 1 — new contract, post-MVP +**Document status:** Revision 2 — red-team findings F1–F3 resolved (`90-decisions.md`, 2026-10-08), post-MVP **Reading order:** after [`11-content-packs.md`](11-content-packs.md), whose `ResolutionId` this treats as an epoch, [`04-core.md`](04-core.md) §2, §4 and §7, which it amends, and @@ -61,33 +61,43 @@ them: | **Adoption** | Moving one session from its epoch to another, recorded at one log position. | | **Pinned** | A session that stays on its epoch, because adoption was refused or never offered. | | **Adoption point** | One of the four places the session store asks the channel and may adopt (§5.3). | +| **Epoch entry** | A log entry that changes `campaignVersion`: a content entry (an adoption) or a migration entry (a migrated load, §3.5). | --- ## 3. Data Model -### 3.1 The log carries content entries +### 3.1 The log carries epoch entries The envelope gains no field. The element type of `actionLog` widens from `LoggedAction` to a -union, and the new member records an adoption: +union. One new member records an adoption; the other records a migrated load (§3.5): ```typescript -type LoggedEntry = LoggedAction | LoggedContent; +type LoggedEntry = LoggedAction | LoggedContent | LoggedMigration; interface LoggedContent { seq: number; // consumes a seq, exactly like an action system: "content"; from: string; // the campaignVersion before adoption - to: string; // the campaignVersion after; equals GameState.campaignVersion until the next one + to: string; // the campaignVersion after; equals GameState.campaignVersion until the next epoch entry +} + +interface LoggedMigration { + seq: number; // consumes a seq, exactly like an action + system: "migration"; + from: string; // the campaignVersion the save was made under + to: string; // the campaignVersion the migration restamped } ``` -`GameState.campaignVersion` remains the one place a state's current epoch lives. A content -entry records the transition, not the state, so the two never disagree: the last entry's `to` -*is* `campaignVersion`, and an engine check enforces it on `deserialize`. +`GameState.campaignVersion` remains the one place a state's current epoch lives. An epoch +entry records the transition, not the state, so the two never disagree: the last epoch entry's +`to` *is* `campaignVersion`, and an engine check enforces it on `deserialize`. **Every change of +`campaignVersion` leaves one** — an adoption a content entry, a migrated load a migration entry +— so the log is the complete history of the epochs a game has run on. **The replay input becomes `{ seed, starting version, actionLog }`, and the starting version -is derived, not stored.** It is the first content entry's `from`, or `campaignVersion` when the +is derived, not stored.** It is the first epoch entry's `from`, or `campaignVersion` when the log has none. A stored `startingCampaignVersion` field would have duplicated what the log already says — the sixth entry in the envelope-duplication ledger, avoided rather than recorded. @@ -104,12 +114,13 @@ unreproducible. ### 3.3 Format version -`GameState.formatVersion` moves from `1` to `2` **only on a state whose log carries a content -entry.** `adoptContent` writes `2` when it appends one; a game that never adopts keeps `1` and -serializes byte-identically to today. The reader accepts both. An engine predating this design +`GameState.formatVersion` moves from `1` to `2` **only on a state whose log carries an epoch +entry.** `adoptContent` writes `2` when it appends a content entry and a migrated load when it +appends a migration entry; a game that never adopts and never migrates across a campaign version +keeps `1` and serializes byte-identically to today. The reader accepts both. An engine predating this design rejects a version-`2` state with `invalid_state`, which is the correct answer — it would read -a content entry as an action with no `actionId` — and still reads every state that never -adopted, which contains nothing it cannot understand. +an epoch entry as an action with no `actionId` — and still reads every state that carries none, +which contains nothing it cannot understand. > **Why not bump unconditionally.** Every golden file and every stored session would change > for a feature none of them use, and an older engine would refuse states it reads perfectly @@ -130,6 +141,38 @@ Strings are part of an epoch because a session renders through the string table a publication that changes a string without changing a version is the silent drift content-addressing exists to refuse. +### 3.5 A migrated load leaves a migration entry + +04 §10.2's migration path restamps `campaignVersion` to the loading campaign's own. Before this +revision it left the log untouched, and on a log that already carried a content entry that broke +C26: a save at `e2` whose last content entry ends at `e2`, migrated onto `e3`, would be a state +whose `campaignVersion` and last `to` disagree — refused `invalid_state` by the next `deserialize` +of the session it had just loaded into (red-team F1). + +So **a migration that changes `campaignVersion` appends a `LoggedMigration { from, to }`**, in +the same step that restamps it and sets `replayCompatible: false`, and stamps `formatVersion: 2`. +It does so whether or not the log already carried content entries: one rule — every change of +`campaignVersion` is logged — keeps C26 universal and keeps the derived starting version +truthful, where a rule that logged only some migrations would leave a migrated log naming a +starting epoch it never ran on. A migration that changes only `kindVersion` restamps no +version and appends nothing. + +**A migration entry is not replayable, and the log says so.** A migration is not a step replay +can perform — the epoch it left is, by construction, one this host could not resolve — so: + +- **The record and the log agree.** A state whose log carries a migration entry is exactly a + state whose lineage passed through a migrated version change, which is what + `replayCompatible: false` already records on the save and the session record. The log now + says it too, so the state alone is enough to know. +- **`branchSession` refuses** such a session, as it already refuses `replayCompatible: false`. +- **Capture has no submission for one** (08 §2), so no fixture carries one. +- **Only the epochs after the last migration entry are live.** Retention (§6) and the + session's string table (§5.7) count the log from there: what came before is history the + session can no longer replay, and nothing resolves through it. + +A migration remains idempotent against its own output (04 §10.2): the second load finds no +version mismatch, runs no migration, and appends no second entry. + --- ## 4. Module Boundaries @@ -288,7 +331,9 @@ kind-version change, which is a save-load concern, and a running engine has one The story-graph kind adopts additive change and refuses anything that could strand the player. After any campaign migration, it adopts iff: -- `currentNodeId` names a node the target has; +- `currentNodeId` names a node the target has, **that node is a `ChoiceNode`, and at least + one of its choices is available** — `showWhen` true and requirements met — against the + adopted state, after the insertion below; - every `visitedCounts` key and every `unlockedAchievements` id exists in the target; - every variable the state carries that the target declares has the target's `VarType`; - no variable the source declared and the state carries is missing from the target. @@ -296,6 +341,17 @@ player. After any campaign migration, it adopts iff: Variables the target declares and the state lacks are inserted at their declared initial value. Everything else passes through unchanged. A refusal reasons `content_incompatible`. +**The node the player stands on must still be a place to act.** An active story-graph session +always rests on a choice node, because `submitChoice` and `createGame` both settle until they +reach one (`20-contract.md`, story-graph §8.2). Adoption runs no settle — settling would draw +randomness and walk nodes, which is play, and C24 forbids it — so a target that turned the +current node into an ending, an auto or a random node, or gated away every choice it offers, +would leave an active scene with nothing to submit: every choice rejects `not_a_choice_node` or +its gate, and an ending the target means is never recorded (red-team F2). Such a target is +refused and the session stays pinned on content where it can still play. An author who means to +end a session in place does so through content the player reaches, not by rewriting the node +under them. + That last condition settles what `validateState` deferred (`20-contract.md`, story-graph §8.1): a save whose campaign later dropped a variable is still tolerated *at load*, because the save is the record and refusing it strands the player; *adoption* refuses the same silent drop, because @@ -305,15 +361,54 @@ drop a variable writes a `migrateState` that drops it. ### 5.6 Replay and branching Replay re-runs a log from its starting version. For each entry: an action goes through -`submitAction`; a content entry goes through `adoptContent(state, entry.to)` and must adopt. A +`submitAction`; a content entry goes through `adoptContent(state, entry.to)` and must adopt; a +migration entry is never reached, because no replayable log carries one (§3.5). A content entry that now refuses is a divergence — the kind's compatibility rule changed, which is precisely what a cross-version oracle exists to catch (07 §6). The C1 harness (04 §14) treats both entry types the same way: byte-identical serialization after every entry. `branchSession` replays the retained prefix the same way, then reaches its own adoption point. -`atActionCount` counts log entries of both types, so a fork may fall either side of an adoption; +`atActionCount` counts log entries of every type, so a fork may fall either side of an adoption; a branch taken before one starts on the old epoch and is offered the new one on return. +### 5.7 Strings across an adoption + +A client renders keys from one call with a table from another: `getScene`, then `getStrings`. +Between the two, another client's `resumeSession` may adopt, and if the new epoch renamed a key +the scene carries, a table drawn from the new epoch alone cannot resolve it. Reading the table +first permits the inverse. Every call respects the session lock; the race is between completed +calls, so the lock cannot close it (red-team F3). + +**So a session's string table spans every epoch the session has run on, and only grows.** +`getStrings(sessionId)` returns the union of the string tables of the session's live epochs — +its starting version, or the last migration entry's `to` when the log carries one (§3.5), and +every content entry's `to` after it — each key resolved from the most recent of those epochs +that defines it. The current epoch therefore wins every key it has, and a key it dropped still +resolves through the epoch that last had it. A session that never adopted gets exactly the table +it always did. + +That makes one property hold, and gives a client one rule: + +- **Any key in any result the store returned for a session resolves in every later + `getStrings` of that session.** Every such key came from an epoch the log named when the + result was built, and within one session id the log only grows (C27). +- **A client that meets a key its cached table lacks fetches the table again**, and the key is + then present. The mismatch the red team found is therefore detectable, and recoverable with + the surface a client already has. + +It also covers the case the race only illustrates: a `submitAction` that adopts after commit +returns messages built on the old epoch beside a scene projected from the new one (§5.3), and +one table resolves both. + +**What it does not promise** is the wording a key had when the result was built. A key both +epochs define resolves to the newer text, so an older scene can show newer wording for a key the +new epoch reworded. That is accepted rather than designed away: the new wording is the content +the host published, and a client that needs the exact old text is asking for the old epoch, +which `getStrings` is not the API for. + +The union adds nothing to retention (§6): every epoch it spans is already named by the session's +log, so retention already keeps it. + --- ## 6. Distribution @@ -335,7 +430,8 @@ engine requires of a multi-instance host is narrow: epoch and the packs once. How it does so is host-owned. **Retention.** An epoch may be dropped only when no live session, no save and no retained -fixture names it — as a starting version or as any content entry's `from` or `to`. Dropping one +fixture names it — as a starting version or as any content entry's `from` or `to`, counting, in a +log that carries a migration entry, only from the last one's `to` (§3.5). Dropping one earlier turns those into `unknown_campaign` at the next load and `campaign_version_missing` at the next replay. The engine cannot see saves or fixtures, so it cannot enforce this; the in-memory archive sidesteps it by never evicting. @@ -351,14 +447,17 @@ in-memory archive sidesteps it by never evicting. | `createEngine` | `archive` lacks a registry campaign at its registered version | Throws — a construction error | | `createGame` | The requested version does not resolve | `unknown_campaign` | | `deserialize` / `migrate` | `(campaignId, campaignVersion)` does not resolve | `unknown_campaign`, emitting `core.deserialize.rejected` — closes the retained gap | -| `deserialize` | Last content entry's `to` ≠ `campaignVersion`, or a content entry in a version-`1` state | `invalid_state` | +| `deserialize` | Last epoch entry's `to` ≠ `campaignVersion`, or an epoch entry in a version-`1` state | `invalid_state` | | Adoption | Target missing; kind changed; seam absent; migration failed; kind refused; result invalid; session ended | Pinned, with the §5.4 reason; the command completes | | Adoption point | Channel throws | No offer; the command completes | | Adoption point | The adoption-only write at `resumeSession` fails (`storage_failure`, `concurrent_modification`) | The store restores; `resumeSession` returns the unadopted scene, and the next adoption point retries | | `loadGame` | The save's epoch is archived and its `kindVersion` matches | Loads on that epoch with no migration — `replayCompatible` kept — then adopts | -| `loadGame` | Otherwise | 04 §10.2's migration path, unchanged | +| `loadGame` | Otherwise | 04 §10.2's migration path; one that changes `campaignVersion` appends a migration entry and stamps `formatVersion: 2` (§3.5) | | `branchSession` | A version the retained prefix names does not resolve | `unknown_campaign` (the existing row, widened) | | `branchSession` | A recorded content entry now refuses | `invalid_state`; nothing written | +| `branchSession` | The log carries a migration entry | `invalid_state` — the existing `replayCompatible: false` refusal, now readable from the log | +| Adoption, story-graph | The target's node at `currentNodeId` is not a choice node, or offers no available choice | Pinned `content_incompatible` (§5.5) | +| `getStrings` | A key an earlier result carried is absent from the current epoch | Resolved through the most recent live epoch that defines it (§5.7) | | Replay | The starting or any recorded version does not resolve | `unrunnable: campaign_version_missing` | | Replay | A recorded adoption now refuses | `diverged` at that index | @@ -401,6 +500,12 @@ in-memory archive sidesteps it by never evicting. | **Reject a version mismatch on `deserialize`** | Turned down in plan 53 (D3): it ends exactly the sessions injection exists for. Resolving the version through the archive replaces the gap rather than closing it by refusal. | | **Bump `formatVersion` unconditionally** | §3.3. | | **Rename `campaign_version_missing` to a new verdict** | The type is public and three suites switch on it; widening what triggers it costs nothing. | +| **Exempt migrated states from C26** (F1) | `replayCompatible` lives on the save and the session record, not on `GameState`, so `deserialize` cannot see an exemption; and an unchecked last `to` is the integrity check removed rather than satisfied. | +| **Log a migration only when the log already carries content entries** (F1) | Satisfies C26 but leaves a migrated log with no content entries deriving a starting epoch it never ran on; one rule for every version change is the smaller contract. | +| **Reuse `LoggedContent` for a migration** (F1) | Replay would read it as an adoption and try to repeat it; a migration's source epoch is one the host could not resolve, so the attempt fails as a divergence that is really a category error. | +| **Settle the story-graph state at adoption** (F2) | Settle walks nodes and may draw randomness — play, which C24 forbids adoption to invent. | +| **Require only that the node's type is unchanged** (F2) | Misses a choice node whose every choice the target gated away — the same stranding by a different edit. | +| **Stamp each `Scene` with its epoch and let `getStrings` take one** (F3) | Exact, but a new field on `Scene`, a new parameter on a store operation and its MCP tool, and still two epochs in one `SessionActionResult` when `submitAction` adopts after commit. The union answers both with no surface change. | --- diff --git a/docs/docs/engine/OPEN-QUESTIONS.md b/docs/docs/engine/OPEN-QUESTIONS.md index 110eee1e..ef24c86a 100644 --- a/docs/docs/engine/OPEN-QUESTIONS.md +++ b/docs/docs/engine/OPEN-QUESTIONS.md @@ -411,8 +411,9 @@ injection exists for. The design is [`16-content-epochs.md`](16-content-epochs.md): an adoption is a logged `LoggedContent` entry, sessions adopt at four adoption points or stay pinned, content resolves by the state's own version through a content-addressed archive, and the kind judges through `Kind.adoptContent?`. -The contract is `20-contract.md` C21–C26, §2, §3, §4, §7, §10 and story-graph §8.1, and the -judgement calls are the 2026-10-08 entries in the decision log. Two refinements on the settled +The contract is `20-contract.md` C21–C27, §2, §3, §4, §7, §10 and story-graph §8.1, and the +judgement calls are the 2026-10-08 entries in the decision log, including the three that resolve +the red team's F1–F3 (`design/redteam/2026-10-08-10-design.md`). Two refinements on the settled directions: absent the seam, a session stays pinned rather than adopting additive change by default, and the replay oracle's new failure widens `campaign_version_missing` rather than replacing it. **Not yet built** — the next step is `/agentkit:plan`. diff --git a/docs/docs/guide.md b/docs/docs/guide.md index 96abee88..1942437c 100644 --- a/docs/docs/guide.md +++ b/docs/docs/guide.md @@ -4,7 +4,7 @@ sidebar_position: 1 sidebar_label: Developer Guide --- - + > Generated from `design/` by `/make-human-docs`. Do not edit by hand — edit the > design docs and regenerate. `/reconcile` reports when this has gone stale. @@ -295,20 +295,25 @@ Three rules are worth planning around: - **Adoption is logged, so replay stays exact.** The action log records the move as a content entry at its position, and replay re-runs it. Unlike a migration, it does not cost - `replayCompatible`. A save only gains the new log format once it carries such an entry, so - existing saves are unchanged. + `replayCompatible`. A migrated load that changes the campaign version is logged too, as a + migration entry, so the log always names every version the game ran on; unlike a content + entry, that one ends replay — a session carrying it cannot be branched or captured. A save + only gains the new log format once it carries either entry, so existing saves are unchanged. - **A session that cannot move stays pinned**, and keeps playing the content it started on. The archive therefore keeps old epochs. Adoption never fails a command: a refusal, or a channel that throws, leaves the session where it was and the command completes. - **The kind judges compatibility.** A kind with no adoption rule never adopts. Story-graph adopts additive change — new nodes, new variables at their initial values — and refuses - anything that could strand the player, such as removing the node they stand on or silently - dropping a variable the state carries. Simulation and world-graph have no rule yet, so their + anything that could strand the player: removing the node they stand on, turning it into + anything but a choice node with a choice they can take, or silently dropping a variable the + state carries. Simulation and world-graph have no rule yet, so their sessions stay pinned. Adoption invents no play: it produces no state change, no outcome message, and nothing reaches -the profile. Telling the player new content arrived is the client's choice. Queries and rejected -actions never adopt. With no channel supplied, behaviour is exactly what it was. +the profile. Telling the player new content arrived is the client's choice. A session's string +table spans every epoch it has run on since its last migration, newest wording winning, so a key +a client was handed never stops resolving; a client that misses a key fetches the table again. +Queries and rejected actions never adopt. With no channel supplied, behaviour is exactly what it was. This is also the delivery half of a story extension. Authoring an extension stays an **attachment** — a pack adding to a campaign it does not own, validated after the fold — and a From 261be6c41b0a3836ee854ce341bc2365659c74e0 Mon Sep 17 00:00:00 2001 From: Ben Richards Date: Thu, 8 Oct 2026 22:07:29 +0300 Subject: [PATCH 2/3] Narrow F1: log a migration only over an epoch-bearing log A migration appends a LoggedMigration only when the log already carries an epoch entry, so a host that migrates but never adopts keeps formatVersion 1 and byte-identical saves (engine rollback stays safe). Capture now refuses every replayCompatible: false session, since a migrated log without epoch entries carries no entry to refuse on. --- design/10-design.md | 55 +++++++++++++++----------- design/20-contract.md | 43 ++++++++++---------- design/90-decisions.md | 6 +-- docs/docs/engine/04-core.md | 40 ++++++++++--------- docs/docs/engine/08-session-capture.md | 5 ++- docs/docs/engine/16-content-epochs.md | 50 ++++++++++++----------- docs/docs/guide.md | 11 +++--- 7 files changed, 115 insertions(+), 95 deletions(-) diff --git a/design/10-design.md b/design/10-design.md index 81fe8e3c..cc4d8724 100644 --- a/design/10-design.md +++ b/design/10-design.md @@ -2225,8 +2225,9 @@ with no translation step and can be promoted into the corpus without conversion A session that adopted new content (16) is captured with an `AdoptionSubmission` (07 §2) at each content entry's position. That is not a new format — the union is 07's — and an adoption carries no caller input to filter: its only value is a version the store chose from the host's -channel, never one a client sent. A session whose log carries a migration entry (16 §3.5) has no -fixture form — a migration is not a step replay can perform — and is not captured. +channel, never one a client sent. A session whose record is `replayCompatible: false` — every +migrated session, including each whose log carries a migration entry (16 §3.5) — has no fixture +form: a migration is not a step replay can perform, so it is not captured. --- @@ -4064,7 +4065,7 @@ them: | **Adoption** | Moving one session from its epoch to another, recorded at one log position. | | **Pinned** | A session that stays on its epoch, because adoption was refused or never offered. | | **Adoption point** | One of the four places the session store asks the channel and may adopt (§5.3). | -| **Epoch entry** | A log entry that changes `campaignVersion`: a content entry (an adoption) or a migration entry (a migrated load, §3.5). | +| **Epoch entry** | A log entry that changes `campaignVersion`: a content entry (an adoption) or a migration entry (a migrated load over a log that already carries an epoch entry, §3.5). | --- @@ -4095,9 +4096,11 @@ interface LoggedMigration { `GameState.campaignVersion` remains the one place a state's current epoch lives. An epoch entry records the transition, not the state, so the two never disagree: the last epoch entry's -`to` *is* `campaignVersion`, and an engine check enforces it on `deserialize`. **Every change of -`campaignVersion` leaves one** — an adoption a content entry, a migrated load a migration entry -— so the log is the complete history of the epochs a game has run on. +`to` *is* `campaignVersion`, and an engine check enforces it on `deserialize`. **Once a log +carries an epoch entry, every change of `campaignVersion` leaves one** — an adoption always +appends a content entry, and a migrated load over such a log appends a migration entry (§3.5). +A log with none has never adopted; if it was migrated, `replayCompatible: false` on its record +says so, and nothing replays it. **The replay input becomes `{ seed, starting version, actionLog }`, and the starting version is derived, not stored.** It is the first epoch entry's `from`, or `campaignVersion` when the @@ -4118,9 +4121,9 @@ unreproducible. ### 3.3 Format version `GameState.formatVersion` moves from `1` to `2` **only on a state whose log carries an epoch -entry.** `adoptContent` writes `2` when it appends a content entry and a migrated load when it -appends a migration entry; a game that never adopts and never migrates across a campaign version -keeps `1` and serializes byte-identically to today. The reader accepts both. An engine predating this design +entry.** `adoptContent` writes `2` when it appends a content entry; a migrated load appends a +migration entry only to a state that is already `2`, and never changes the stamp. A game that +never adopts keeps `1` — through a migrated load too — and serializes byte-identically to today. The reader accepts both. An engine predating this design rejects a version-`2` state with `invalid_state`, which is the correct answer — it would read an epoch entry as an action with no `actionId` — and still reads every state that carries none, which contains nothing it cannot understand. @@ -4152,23 +4155,27 @@ C26: a save at `e2` whose last content entry ends at `e2`, migrated onto `e3`, w whose `campaignVersion` and last `to` disagree — refused `invalid_state` by the next `deserialize` of the session it had just loaded into (red-team F1). -So **a migration that changes `campaignVersion` appends a `LoggedMigration { from, to }`**, in -the same step that restamps it and sets `replayCompatible: false`, and stamps `formatVersion: 2`. -It does so whether or not the log already carried content entries: one rule — every change of -`campaignVersion` is logged — keeps C26 universal and keeps the derived starting version -truthful, where a rule that logged only some migrations would leave a migrated log naming a -starting epoch it never ran on. A migration that changes only `kindVersion` restamps no -version and appends nothing. +So **a migration that changes `campaignVersion` on a log that already carries an epoch entry +appends a `LoggedMigration { from, to }`**, in the same step that restamps it and sets +`replayCompatible: false`. The state is already `formatVersion: 2`, and stays so. A migration +over a log with no epoch entry appends nothing: C26 holds there without one, and the state keeps +`formatVersion: 1`, so a host that migrates but never adopts writes exactly the saves it writes +today, and an engine predating epochs still reads them. Such a log's derived starting version +names the epoch it migrated onto rather than the one its actions were taken on — but the log is +never replayed (`replayCompatible: false`), and the strings and retention rules read that epoch +correctly. A migration that changes only `kindVersion` restamps no version and appends nothing. **A migration entry is not replayable, and the log says so.** A migration is not a step replay can perform — the epoch it left is, by construction, one this host could not resolve — so: -- **The record and the log agree.** A state whose log carries a migration entry is exactly a - state whose lineage passed through a migrated version change, which is what - `replayCompatible: false` already records on the save and the session record. The log now - says it too, so the state alone is enough to know. +- **The entry implies the record.** A state whose log carries a migration entry passed through + a migrated load, so its save and session record are `replayCompatible: false`. The converse + does not hold — a migrated log with no epoch entry carries no migration entry — so the + record stays the authority, and the entry makes the refusal readable from the state where one + exists. - **`branchSession` refuses** such a session, as it already refuses `replayCompatible: false`. -- **Capture has no submission for one** (08 §2), so no fixture carries one. +- **Capture refuses every `replayCompatible: false` session** (08 §2), so no fixture carries a + migration entry. - **Only the epochs after the last migration entry are live.** Retention (§6) and the session's string table (§5.7) count the log from there: what came before is history the session can no longer replay, and nothing resolves through it. @@ -4455,10 +4462,10 @@ in-memory archive sidesteps it by never evicting. | Adoption point | Channel throws | No offer; the command completes | | Adoption point | The adoption-only write at `resumeSession` fails (`storage_failure`, `concurrent_modification`) | The store restores; `resumeSession` returns the unadopted scene, and the next adoption point retries | | `loadGame` | The save's epoch is archived and its `kindVersion` matches | Loads on that epoch with no migration — `replayCompatible` kept — then adopts | -| `loadGame` | Otherwise | 04 §10.2's migration path; one that changes `campaignVersion` appends a migration entry and stamps `formatVersion: 2` (§3.5) | +| `loadGame` | Otherwise | 04 §10.2's migration path; one that changes `campaignVersion` on a log carrying an epoch entry appends a migration entry (§3.5) | | `branchSession` | A version the retained prefix names does not resolve | `unknown_campaign` (the existing row, widened) | | `branchSession` | A recorded content entry now refuses | `invalid_state`; nothing written | -| `branchSession` | The log carries a migration entry | `invalid_state` — the existing `replayCompatible: false` refusal, now readable from the log | +| `branchSession` | The log carries a migration entry | `invalid_state` — the existing `replayCompatible: false` refusal, readable from the log where the entry exists | | Adoption, story-graph | The target's node at `currentNodeId` is not a choice node, or offers no available choice | Pinned `content_incompatible` (§5.5) | | `getStrings` | A key an earlier result carried is absent from the current epoch | Resolved through the most recent live epoch that defines it (§5.7) | | Replay | The starting or any recorded version does not resolve | `unrunnable: campaign_version_missing` | @@ -4504,7 +4511,7 @@ in-memory archive sidesteps it by never evicting. | **Bump `formatVersion` unconditionally** | §3.3. | | **Rename `campaign_version_missing` to a new verdict** | The type is public and three suites switch on it; widening what triggers it costs nothing. | | **Exempt migrated states from C26** (F1) | `replayCompatible` lives on the save and the session record, not on `GameState`, so `deserialize` cannot see an exemption; and an unchecked last `to` is the integrity check removed rather than satisfied. | -| **Log a migration only when the log already carries content entries** (F1) | Satisfies C26 but leaves a migrated log with no content entries deriving a starting epoch it never ran on; one rule for every version change is the smaller contract. | +| **Log every migration that changes `campaignVersion`** (F1) | One unconditional rule, and the log would name every epoch a game ran on — but every migrated save of a host that never adopts would become `formatVersion: 2`, breaking the byte-identical promise of §3.3 and stranding those saves on an engine rollback. What it buys is a truthful starting version on logs that are never replayed. | | **Reuse `LoggedContent` for a migration** (F1) | Replay would read it as an adoption and try to repeat it; a migration's source epoch is one the host could not resolve, so the attempt fails as a divergence that is really a category error. | | **Settle the story-graph state at adoption** (F2) | Settle walks nodes and may draw randomness — play, which C24 forbids adoption to invent. | | **Require only that the node's type is unchanged** (F2) | Misses a choice node whose every choice the target gated away — the same stranding by a different edit. | diff --git a/design/20-contract.md b/design/20-contract.md index c5197d29..44190cc6 100644 --- a/design/20-contract.md +++ b/design/20-contract.md @@ -132,7 +132,8 @@ defeat it (§9.1). runs both entry types.* - **C22.** A pinned session's content never changes. Every state-taking operation resolves content by the state's own `(campaignId, campaignVersion)`, and `campaignVersion` changes only - through `adoptContent` or a migrated load, each of which appends an epoch entry (§2). *Enforced by code — resolution by state, over an + through `adoptContent`, which appends a content entry, or a migrated load, which appends a + migration entry when the log already carries an epoch entry (§2, §10.2). *Enforced by code — resolution by state, over an archive in which one key names one content (C23).* - **C23.** The archive serves only content that passed `validateCampaign`, and a key, once held, names the same content forever. *Enforced by code for the in-memory archive — `publish` @@ -319,7 +320,7 @@ interface LoggedContent { // an adoption (16 §3.1) to: string; // the campaignVersion after — the last epoch entry's `to` IS campaignVersion } -interface LoggedMigration { // a migrated load that changed campaignVersion (§10.2; 16 §3.5) +interface LoggedMigration { // a migrated load that changed campaignVersion on a log already carrying an epoch entry (§10.2; 16 §3.5) seq: number; // consumes a seq, exactly like an action system: "migration"; from: string; // the campaignVersion the save was made under @@ -331,12 +332,13 @@ interface LoggedMigration { // a migrated load that changed campaignVersion content mid-play (§4, `adoptContent`; [`16-content-epochs.md`](16-content-epochs.md)) appends a `LoggedContent` entry, so the replay input is `{ seed, starting version, actionLog }`, where the starting version is the first epoch entry's `from`, or `campaignVersion` when the log carries none. It is not stored: a -`startingCampaignVersion` field would duplicate what the log already says. **Every change of -`campaignVersion` is an epoch entry** — an adoption a `LoggedContent`, a migrated load a -`LoggedMigration` (§10.2) — so the last epoch entry's `to` is always the current version. -`formatVersion` is `2` exactly on a state whose log carries an epoch entry, and `1` otherwise — -a game that never adopts and never migrates across a campaign version serializes -byte-identically to one written before content epochs existed (C26). +`startingCampaignVersion` field would duplicate what the log already says. **Once a log carries +an epoch entry, every change of `campaignVersion` is one** — an adoption a `LoggedContent`, a +migrated load a `LoggedMigration` (§10.2) — so the last epoch entry's `to` is always the current +version. A migrated log with no epoch entry stays without one; its record's +`replayCompatible: false` is what keeps it from being replayed. `formatVersion` is `2` exactly +on a state whose log carries an epoch entry, and `1` otherwise — a game that never adopts +serializes byte-identically to one written before content epochs existed, migrated or not (C26). **What lives here vs in `kindState`.** The envelope holds everything a game has *regardless of kind*: identity, campaign reference, seed, status, and the action @@ -1819,8 +1821,8 @@ An anonymous session branches to an anonymous one. `replayCompatible: false` has passed through a migrated load (§10.2), and its log is no longer guaranteed to regenerate its state — which is the entire mechanism a branch depends on. It raises `invalid_state` and writes nothing. A source whose log carries a migration entry (§2) is -refused the same way; the two coincide for every version-changing migration, and the log entry -makes the refusal readable from the state alone. Failing here is the sticky-forward rule doing its +refused the same way; the entry implies the flag, and makes the refusal readable from the state +where it exists. Failing here is the sticky-forward rule doing its job: the alternative is a branch that silently diverges from the game it claims to continue. #### Reproducing a stored session from its log @@ -2249,16 +2251,17 @@ load the same way: - **A successful migration** sets `replayCompatible: false`, sticky forward — once a lineage has passed through a migrated load, it never becomes replay-compatible again, even across further saves that need no further migration. -- **A migration that changes `campaignVersion` logs it.** In the same step that restamps the - version, it appends `LoggedMigration { seq: actionLog.length, system: "migration", from, to }` - and stamps `formatVersion: 2` (§2). Without the entry, a save whose log already carried a - content entry would migrate into a state C26 refuses — its last `to` naming the epoch it left - ([`16-content-epochs.md`](16-content-epochs.md) §3.5; red-team F1). The entry is appended for - every version-changing migration, not only those over a content-bearing log, so the log is - always the complete history of the versions a game ran on. A migration that changes only - `kindVersion` restamps no version and appends nothing. A migration entry is never replayed: - `branchSession` refuses a log carrying one (§7.4), capture records none, and §14's runner - fails a fixture that carries one. +- **A migration that changes `campaignVersion` over an epoch-bearing log logs it.** When the + log already carries an epoch entry, the step that restamps the version also appends + `LoggedMigration { seq: actionLog.length, system: "migration", from, to }`; the state is + already `formatVersion: 2` and stays so (§2). Without the entry, such a save would migrate + into a state C26 refuses — its last `to` naming the epoch it left + ([`16-content-epochs.md`](16-content-epochs.md) §3.5; red-team F1). A migration over a log + with no epoch entry appends nothing and keeps `formatVersion: 1`, so a host that never adopts + writes the saves it wrote before content epochs. A migration that changes only `kindVersion` + restamps no version and appends nothing. A migration entry is never replayed: + `branchSession` refuses a log carrying one (§7.4), capture refuses every + `replayCompatible: false` session, and §14's runner fails a fixture that carries one. - **A migration is idempotent against its own output.** Migrating a save, saving it, and migrating again — which is what happens whenever a player loads, plays nothing, and saves, or whenever a fixture is regenerated — must reach a canonically identical `kindState`. The diff --git a/design/90-decisions.md b/design/90-decisions.md index 870777d5..49402acc 100644 --- a/design/90-decisions.md +++ b/design/90-decisions.md @@ -2560,9 +2560,9 @@ Reversibility: a dedicated contract can still be designed if attachment gaps pro ### 2026-10-08 — A migrated load that changes `campaignVersion` logs a migration entry (red-team F1) Context: red-team F1 (`design/redteam/2026-10-08-10-design.md`, BLOCKING). `20-contract.md` §10.2's fallback migration restamps `campaignVersion` and leaves `actionLog` untouched. A `formatVersion: 2` save whose last content entry ends at `e2`, loaded on a host that no longer archives `e2` and migrated onto `e3`, therefore became a state whose last `to` disagreed with its `campaignVersion`. C26 and `deserialize` refuse that as `invalid_state` on the next hydration or re-save. `replayCompatible: false` cannot exempt it, because that flag lives on the save and the session record, not on `GameState`. -Chosen: a new log member, `LoggedMigration { seq, system: "migration", from, to }`. A migration that changes `campaignVersion` appends one in the same step that restamps the version, and stamps `formatVersion: 2`. "Epoch entry" names a content entry or a migration entry. C26 is restated over epoch entries, C22 says every version change appends one, and the derived starting version is the first epoch entry's `from`. The entry is appended for every version-changing migration, not only over a log that already carries content entries, so the log is always the complete history of the versions a game ran on. A kind-shape-only migration appends nothing. A migration entry is never replayed. `branchSession` refuses a log carrying one, which is the existing `replayCompatible: false` refusal made readable from the state. Capture records none, and the §14 runner fails a fixture carrying one. Retention and the session string table count the log only from the last migration entry's `to`. Idempotency is unchanged: a second load finds no mismatch and appends nothing. The design is `16-content-epochs.md` §3.5; the contract is `20-contract.md` C3, C22, C26, §2, §4 `deserialize`, §7.4 and §10.2. -Rejected: **Exempting migrated states from C26.** `deserialize` cannot see the exemption, and the integrity check would be removed rather than satisfied. **Appending only when the log already carries content entries.** It satisfies C26 but leaves a migrated log without content entries deriving a starting epoch it never ran on. **Reusing `LoggedContent`.** Replay would treat the entry as an adoption and try to repeat a move whose source epoch the host could not resolve. -Reversibility: design only until content epochs are built. `src/engine/src/core/persistence/envelope.ts` still restamps without an entry, which is consistent today because no state yet carries a content entry; the entry lands with the content-epochs implementation. After that it is persisted in migrated saves, and every migrated save made from then on is `formatVersion: 2`, which an engine predating epochs refuses. That is a real cost to hosts that migrate but never adopt. +Chosen: a new log member, `LoggedMigration { seq, system: "migration", from, to }`. A migration that changes `campaignVersion` on a log that already carries an epoch entry appends one in the same step that restamps the version; that state is already `formatVersion: 2`. "Epoch entry" names a content entry or a migration entry. C26 is restated over epoch entries, C22 says which version changes append one, and the derived starting version is the first epoch entry's `from`. A migration over a log with no epoch entry appends nothing and keeps `formatVersion: 1`, so Revision 1's promise holds unchanged: a game that never adopts serializes byte-identically to one written before content epochs, migrated or not. A kind-shape-only migration appends nothing. A migration entry is never replayed. `branchSession` refuses a log carrying one, which is the existing `replayCompatible: false` refusal made readable from the state where the entry exists. Capture refuses every `replayCompatible: false` session, and the §14 runner fails a fixture carrying a migration entry. The scope was first drafted as every version-changing migration and narrowed on review, with the user's sign-off. Retention and the session string table count the log only from the last migration entry's `to`. Idempotency is unchanged: a second load finds no mismatch and appends nothing. The design is `16-content-epochs.md` §3.5; the contract is `20-contract.md` C3, C22, C26, §2, §4 `deserialize`, §7.4 and §10.2. +Rejected: **Exempting migrated states from C26.** `deserialize` cannot see the exemption, and the integrity check would be removed rather than satisfied. **Appending for every version-changing migration.** One unconditional rule, and the log would name every epoch a game ran on. But every migrated save of a host that never adopts would become `formatVersion: 2`, breaking the byte-identical promise and stranding those saves if the host rolls its engine back. What it buys is a truthful starting version on logs that are never replayed: under the chosen scope, a migrated log with no epoch entry derives the epoch it migrated onto, which nothing reads for replay, and the strings and retention rules read correctly. **Reusing `LoggedContent`.** Replay would treat the entry as an adoption and try to repeat a move whose source epoch the host could not resolve. +Reversibility: design only until content epochs are built. `src/engine/src/core/persistence/envelope.ts` restamps without an entry, which stays correct for every log with no epoch entry; the conditional append lands with the content-epochs implementation. After that the entry is persisted in migrated saves that had adopted. Widening the rule to every migration later is possible but changes the format of saves from hosts that never adopt. ### 2026-10-08 — Story-graph adoption requires the current node to stay a choice node with an available choice (red-team F2) Context: red-team F2 (`design/redteam/2026-10-08-10-design.md`, LOCAL). Every story-graph adoption predicate checked that the referenced ids *exist*. A target that turned the current choice node into an ending node passed all of them, and so did `validateState`. Adoption runs no settle, so the session was left active with no available action: every choice rejected `not_a_choice_node`, and the ending was never recorded. diff --git a/docs/docs/engine/04-core.md b/docs/docs/engine/04-core.md index 48a6b778..8d6afcc9 100644 --- a/docs/docs/engine/04-core.md +++ b/docs/docs/engine/04-core.md @@ -137,7 +137,7 @@ interface LoggedContent { // an adoption (16 §3.1) to: string; // the campaignVersion after — the last epoch entry's `to` IS campaignVersion } -interface LoggedMigration { // a migrated load that changed campaignVersion (§10.2; 16 §3.5) +interface LoggedMigration { // a migrated load that changed campaignVersion on a log already carrying an epoch entry (§10.2; 16 §3.5) seq: number; // consumes a seq, exactly like an action system: "migration"; from: string; // the campaignVersion the save was made under @@ -149,12 +149,13 @@ interface LoggedMigration { // a migrated load that changed campaignVersion content mid-play (§4, `adoptContent`; [`16-content-epochs.md`](16-content-epochs.md)) appends a `LoggedContent` entry, so the replay input is `{ seed, starting version, actionLog }`, where the starting version is the first epoch entry's `from`, or `campaignVersion` when the log carries none. It is not stored: a -`startingCampaignVersion` field would duplicate what the log already says. **Every change of -`campaignVersion` is an epoch entry** — an adoption a `LoggedContent`, a migrated load a -`LoggedMigration` (§10.2) — so the last epoch entry's `to` is always the current version. -`formatVersion` is `2` exactly on a state whose log carries an epoch entry, and `1` otherwise — -a game that never adopts and never migrates across a campaign version serializes -byte-identically to one written before content epochs existed (C26). +`startingCampaignVersion` field would duplicate what the log already says. **Once a log carries +an epoch entry, every change of `campaignVersion` is one** — an adoption a `LoggedContent`, a +migrated load a `LoggedMigration` (§10.2) — so the last epoch entry's `to` is always the current +version. A migrated log with no epoch entry stays without one; its record's +`replayCompatible: false` is what keeps it from being replayed. `formatVersion` is `2` exactly +on a state whose log carries an epoch entry, and `1` otherwise — a game that never adopts +serializes byte-identically to one written before content epochs existed, migrated or not (C26). **What lives here vs in `kindState`.** The envelope holds everything a game has *regardless of kind*: identity, campaign reference, seed, status, and the action @@ -1637,8 +1638,8 @@ An anonymous session branches to an anonymous one. `replayCompatible: false` has passed through a migrated load (§10.2), and its log is no longer guaranteed to regenerate its state — which is the entire mechanism a branch depends on. It raises `invalid_state` and writes nothing. A source whose log carries a migration entry (§2) is -refused the same way; the two coincide for every version-changing migration, and the log entry -makes the refusal readable from the state alone. Failing here is the sticky-forward rule doing its +refused the same way; the entry implies the flag, and makes the refusal readable from the state +where it exists. Failing here is the sticky-forward rule doing its job: the alternative is a branch that silently diverges from the game it claims to continue. #### Reproducing a stored session from its log @@ -2067,16 +2068,17 @@ load the same way: - **A successful migration** sets `replayCompatible: false`, sticky forward — once a lineage has passed through a migrated load, it never becomes replay-compatible again, even across further saves that need no further migration. -- **A migration that changes `campaignVersion` logs it.** In the same step that restamps the - version, it appends `LoggedMigration { seq: actionLog.length, system: "migration", from, to }` - and stamps `formatVersion: 2` (§2). Without the entry, a save whose log already carried a - content entry would migrate into a state C26 refuses — its last `to` naming the epoch it left - ([`16-content-epochs.md`](16-content-epochs.md) §3.5; red-team F1). The entry is appended for - every version-changing migration, not only those over a content-bearing log, so the log is - always the complete history of the versions a game ran on. A migration that changes only - `kindVersion` restamps no version and appends nothing. A migration entry is never replayed: - `branchSession` refuses a log carrying one (§7.4), capture records none, and §14's runner - fails a fixture that carries one. +- **A migration that changes `campaignVersion` over an epoch-bearing log logs it.** When the + log already carries an epoch entry, the step that restamps the version also appends + `LoggedMigration { seq: actionLog.length, system: "migration", from, to }`; the state is + already `formatVersion: 2` and stays so (§2). Without the entry, such a save would migrate + into a state C26 refuses — its last `to` naming the epoch it left + ([`16-content-epochs.md`](16-content-epochs.md) §3.5; red-team F1). A migration over a log + with no epoch entry appends nothing and keeps `formatVersion: 1`, so a host that never adopts + writes the saves it wrote before content epochs. A migration that changes only `kindVersion` + restamps no version and appends nothing. A migration entry is never replayed: + `branchSession` refuses a log carrying one (§7.4), capture refuses every + `replayCompatible: false` session, and §14's runner fails a fixture that carries one. - **A migration is idempotent against its own output.** Migrating a save, saving it, and migrating again — which is what happens whenever a player loads, plays nothing, and saves, or whenever a fixture is regenerated — must reach a canonically identical `kindState`. The diff --git a/docs/docs/engine/08-session-capture.md b/docs/docs/engine/08-session-capture.md index 421063e3..1e59bbae 100644 --- a/docs/docs/engine/08-session-capture.md +++ b/docs/docs/engine/08-session-capture.md @@ -63,8 +63,9 @@ with no translation step and can be promoted into the corpus without conversion A session that adopted new content (16) is captured with an `AdoptionSubmission` (07 §2) at each content entry's position. That is not a new format — the union is 07's — and an adoption carries no caller input to filter: its only value is a version the store chose from the host's -channel, never one a client sent. A session whose log carries a migration entry (16 §3.5) has no -fixture form — a migration is not a step replay can perform — and is not captured. +channel, never one a client sent. A session whose record is `replayCompatible: false` — every +migrated session, including each whose log carries a migration entry (16 §3.5) — has no fixture +form: a migration is not a step replay can perform, so it is not captured. --- diff --git a/docs/docs/engine/16-content-epochs.md b/docs/docs/engine/16-content-epochs.md index 728e94da..69c884f4 100644 --- a/docs/docs/engine/16-content-epochs.md +++ b/docs/docs/engine/16-content-epochs.md @@ -61,7 +61,7 @@ them: | **Adoption** | Moving one session from its epoch to another, recorded at one log position. | | **Pinned** | A session that stays on its epoch, because adoption was refused or never offered. | | **Adoption point** | One of the four places the session store asks the channel and may adopt (§5.3). | -| **Epoch entry** | A log entry that changes `campaignVersion`: a content entry (an adoption) or a migration entry (a migrated load, §3.5). | +| **Epoch entry** | A log entry that changes `campaignVersion`: a content entry (an adoption) or a migration entry (a migrated load over a log that already carries an epoch entry, §3.5). | --- @@ -92,9 +92,11 @@ interface LoggedMigration { `GameState.campaignVersion` remains the one place a state's current epoch lives. An epoch entry records the transition, not the state, so the two never disagree: the last epoch entry's -`to` *is* `campaignVersion`, and an engine check enforces it on `deserialize`. **Every change of -`campaignVersion` leaves one** — an adoption a content entry, a migrated load a migration entry -— so the log is the complete history of the epochs a game has run on. +`to` *is* `campaignVersion`, and an engine check enforces it on `deserialize`. **Once a log +carries an epoch entry, every change of `campaignVersion` leaves one** — an adoption always +appends a content entry, and a migrated load over such a log appends a migration entry (§3.5). +A log with none has never adopted; if it was migrated, `replayCompatible: false` on its record +says so, and nothing replays it. **The replay input becomes `{ seed, starting version, actionLog }`, and the starting version is derived, not stored.** It is the first epoch entry's `from`, or `campaignVersion` when the @@ -115,9 +117,9 @@ unreproducible. ### 3.3 Format version `GameState.formatVersion` moves from `1` to `2` **only on a state whose log carries an epoch -entry.** `adoptContent` writes `2` when it appends a content entry and a migrated load when it -appends a migration entry; a game that never adopts and never migrates across a campaign version -keeps `1` and serializes byte-identically to today. The reader accepts both. An engine predating this design +entry.** `adoptContent` writes `2` when it appends a content entry; a migrated load appends a +migration entry only to a state that is already `2`, and never changes the stamp. A game that +never adopts keeps `1` — through a migrated load too — and serializes byte-identically to today. The reader accepts both. An engine predating this design rejects a version-`2` state with `invalid_state`, which is the correct answer — it would read an epoch entry as an action with no `actionId` — and still reads every state that carries none, which contains nothing it cannot understand. @@ -149,23 +151,27 @@ C26: a save at `e2` whose last content entry ends at `e2`, migrated onto `e3`, w whose `campaignVersion` and last `to` disagree — refused `invalid_state` by the next `deserialize` of the session it had just loaded into (red-team F1). -So **a migration that changes `campaignVersion` appends a `LoggedMigration { from, to }`**, in -the same step that restamps it and sets `replayCompatible: false`, and stamps `formatVersion: 2`. -It does so whether or not the log already carried content entries: one rule — every change of -`campaignVersion` is logged — keeps C26 universal and keeps the derived starting version -truthful, where a rule that logged only some migrations would leave a migrated log naming a -starting epoch it never ran on. A migration that changes only `kindVersion` restamps no -version and appends nothing. +So **a migration that changes `campaignVersion` on a log that already carries an epoch entry +appends a `LoggedMigration { from, to }`**, in the same step that restamps it and sets +`replayCompatible: false`. The state is already `formatVersion: 2`, and stays so. A migration +over a log with no epoch entry appends nothing: C26 holds there without one, and the state keeps +`formatVersion: 1`, so a host that migrates but never adopts writes exactly the saves it writes +today, and an engine predating epochs still reads them. Such a log's derived starting version +names the epoch it migrated onto rather than the one its actions were taken on — but the log is +never replayed (`replayCompatible: false`), and the strings and retention rules read that epoch +correctly. A migration that changes only `kindVersion` restamps no version and appends nothing. **A migration entry is not replayable, and the log says so.** A migration is not a step replay can perform — the epoch it left is, by construction, one this host could not resolve — so: -- **The record and the log agree.** A state whose log carries a migration entry is exactly a - state whose lineage passed through a migrated version change, which is what - `replayCompatible: false` already records on the save and the session record. The log now - says it too, so the state alone is enough to know. +- **The entry implies the record.** A state whose log carries a migration entry passed through + a migrated load, so its save and session record are `replayCompatible: false`. The converse + does not hold — a migrated log with no epoch entry carries no migration entry — so the + record stays the authority, and the entry makes the refusal readable from the state where one + exists. - **`branchSession` refuses** such a session, as it already refuses `replayCompatible: false`. -- **Capture has no submission for one** (08 §2), so no fixture carries one. +- **Capture refuses every `replayCompatible: false` session** (08 §2), so no fixture carries a + migration entry. - **Only the epochs after the last migration entry are live.** Retention (§6) and the session's string table (§5.7) count the log from there: what came before is history the session can no longer replay, and nothing resolves through it. @@ -452,10 +458,10 @@ in-memory archive sidesteps it by never evicting. | Adoption point | Channel throws | No offer; the command completes | | Adoption point | The adoption-only write at `resumeSession` fails (`storage_failure`, `concurrent_modification`) | The store restores; `resumeSession` returns the unadopted scene, and the next adoption point retries | | `loadGame` | The save's epoch is archived and its `kindVersion` matches | Loads on that epoch with no migration — `replayCompatible` kept — then adopts | -| `loadGame` | Otherwise | 04 §10.2's migration path; one that changes `campaignVersion` appends a migration entry and stamps `formatVersion: 2` (§3.5) | +| `loadGame` | Otherwise | 04 §10.2's migration path; one that changes `campaignVersion` on a log carrying an epoch entry appends a migration entry (§3.5) | | `branchSession` | A version the retained prefix names does not resolve | `unknown_campaign` (the existing row, widened) | | `branchSession` | A recorded content entry now refuses | `invalid_state`; nothing written | -| `branchSession` | The log carries a migration entry | `invalid_state` — the existing `replayCompatible: false` refusal, now readable from the log | +| `branchSession` | The log carries a migration entry | `invalid_state` — the existing `replayCompatible: false` refusal, readable from the log where the entry exists | | Adoption, story-graph | The target's node at `currentNodeId` is not a choice node, or offers no available choice | Pinned `content_incompatible` (§5.5) | | `getStrings` | A key an earlier result carried is absent from the current epoch | Resolved through the most recent live epoch that defines it (§5.7) | | Replay | The starting or any recorded version does not resolve | `unrunnable: campaign_version_missing` | @@ -501,7 +507,7 @@ in-memory archive sidesteps it by never evicting. | **Bump `formatVersion` unconditionally** | §3.3. | | **Rename `campaign_version_missing` to a new verdict** | The type is public and three suites switch on it; widening what triggers it costs nothing. | | **Exempt migrated states from C26** (F1) | `replayCompatible` lives on the save and the session record, not on `GameState`, so `deserialize` cannot see an exemption; and an unchecked last `to` is the integrity check removed rather than satisfied. | -| **Log a migration only when the log already carries content entries** (F1) | Satisfies C26 but leaves a migrated log with no content entries deriving a starting epoch it never ran on; one rule for every version change is the smaller contract. | +| **Log every migration that changes `campaignVersion`** (F1) | One unconditional rule, and the log would name every epoch a game ran on — but every migrated save of a host that never adopts would become `formatVersion: 2`, breaking the byte-identical promise of §3.3 and stranding those saves on an engine rollback. What it buys is a truthful starting version on logs that are never replayed. | | **Reuse `LoggedContent` for a migration** (F1) | Replay would read it as an adoption and try to repeat it; a migration's source epoch is one the host could not resolve, so the attempt fails as a divergence that is really a category error. | | **Settle the story-graph state at adoption** (F2) | Settle walks nodes and may draw randomness — play, which C24 forbids adoption to invent. | | **Require only that the node's type is unchanged** (F2) | Misses a choice node whose every choice the target gated away — the same stranding by a different edit. | diff --git a/docs/docs/guide.md b/docs/docs/guide.md index 1942437c..6899ee82 100644 --- a/docs/docs/guide.md +++ b/docs/docs/guide.md @@ -4,7 +4,7 @@ sidebar_position: 1 sidebar_label: Developer Guide --- - + > Generated from `design/` by `/make-human-docs`. Do not edit by hand — edit the > design docs and regenerate. `/reconcile` reports when this has gone stale. @@ -295,10 +295,11 @@ Three rules are worth planning around: - **Adoption is logged, so replay stays exact.** The action log records the move as a content entry at its position, and replay re-runs it. Unlike a migration, it does not cost - `replayCompatible`. A migrated load that changes the campaign version is logged too, as a - migration entry, so the log always names every version the game ran on; unlike a content - entry, that one ends replay — a session carrying it cannot be branched or captured. A save - only gains the new log format once it carries either entry, so existing saves are unchanged. + `replayCompatible`. Once a log carries a content entry, a migrated load that changes the + campaign version is logged too, as a migration entry, so the version in the log and the + envelope never disagree; unlike a content entry, that one ends replay. A save only gains the + new log format once it adopts, so a host that never adopts — even one that migrates — writes + exactly the saves it writes today. - **A session that cannot move stays pinned**, and keeps playing the content it started on. The archive therefore keeps old epochs. Adoption never fails a command: a refusal, or a channel that throws, leaves the session where it was and the command completes. From 06262acc6b819c3cbacdd50c96684008fa9f4e40 Mon Sep 17 00:00:00 2001 From: Ben Richards Date: Fri, 9 Oct 2026 00:45:23 +0300 Subject: [PATCH 3/3] Back out the capture change; record the older gap instead Refusing every replayCompatible: false session fixes a capture gap that predates content epochs, so it is a separate unit, not part of F1. 08 is back to refusing a session whose log carries a migration entry, and the gap is in the open register. --- design/10-design.md | 8 +++----- design/20-contract.md | 4 ++-- design/90-decisions.md | 13 ++++++++++++- design/OPEN-QUESTIONS.md | 2 ++ docs/docs/engine/04-core.md | 4 ++-- docs/docs/engine/08-session-capture.md | 5 ++--- docs/docs/engine/16-content-epochs.md | 3 +-- docs/docs/engine/OPEN-QUESTIONS.md | 11 +++++++++++ docs/docs/guide.md | 2 +- 9 files changed, 36 insertions(+), 16 deletions(-) diff --git a/design/10-design.md b/design/10-design.md index cc4d8724..a062b9d8 100644 --- a/design/10-design.md +++ b/design/10-design.md @@ -2225,9 +2225,8 @@ with no translation step and can be promoted into the corpus without conversion A session that adopted new content (16) is captured with an `AdoptionSubmission` (07 §2) at each content entry's position. That is not a new format — the union is 07's — and an adoption carries no caller input to filter: its only value is a version the store chose from the host's -channel, never one a client sent. A session whose record is `replayCompatible: false` — every -migrated session, including each whose log carries a migration entry (16 §3.5) — has no fixture -form: a migration is not a step replay can perform, so it is not captured. +channel, never one a client sent. A session whose log carries a migration entry (16 §3.5) has no +fixture form — a migration is not a step replay can perform — and is not captured. --- @@ -4174,8 +4173,7 @@ can perform — the epoch it left is, by construction, one this host could not r record stays the authority, and the entry makes the refusal readable from the state where one exists. - **`branchSession` refuses** such a session, as it already refuses `replayCompatible: false`. -- **Capture refuses every `replayCompatible: false` session** (08 §2), so no fixture carries a - migration entry. +- **Capture has no submission for one** (08 §2), so no fixture carries one. - **Only the epochs after the last migration entry are live.** Retention (§6) and the session's string table (§5.7) count the log from there: what came before is history the session can no longer replay, and nothing resolves through it. diff --git a/design/20-contract.md b/design/20-contract.md index 44190cc6..d76b0eb6 100644 --- a/design/20-contract.md +++ b/design/20-contract.md @@ -2260,8 +2260,8 @@ load the same way: with no epoch entry appends nothing and keeps `formatVersion: 1`, so a host that never adopts writes the saves it wrote before content epochs. A migration that changes only `kindVersion` restamps no version and appends nothing. A migration entry is never replayed: - `branchSession` refuses a log carrying one (§7.4), capture refuses every - `replayCompatible: false` session, and §14's runner fails a fixture that carries one. + `branchSession` refuses a log carrying one (§7.4), capture records none, and §14's runner + fails a fixture that carries one. - **A migration is idempotent against its own output.** Migrating a save, saving it, and migrating again — which is what happens whenever a player loads, plays nothing, and saves, or whenever a fixture is regenerated — must reach a canonically identical `kindState`. The diff --git a/design/90-decisions.md b/design/90-decisions.md index 49402acc..c7bc8b02 100644 --- a/design/90-decisions.md +++ b/design/90-decisions.md @@ -604,6 +604,17 @@ neither. Each is a gap in attachments, not a reason for a second extension contr extension, above). **Revisit** when Adventures moves its extensions onto attachments, which is what retires its merge. +### Found by the content-epochs red-team revision + +**Capture does not refuse a migrated session that carries no migration entry.** 08 refuses a +session whose log carries a migration entry, but nothing in 08 checks `replayCompatible`. A +session migrated across campaign versions before it ever adopted, or migrated in kind shape only, +carries no entry, so capture would turn it into a fixture whose actions were taken on content +the fixture does not name. The gap predates content epochs. Narrowing F1's migration entry to +epoch-bearing logs (2026-10-08) left it as it was rather than opening it. The likely fix is one +line in 08: refuse every `replayCompatible: false` session. It was kept out of the F1 revision +because it is a separate unit. **Revisit** before capture is built. + --- ## 3. Judgement Calls to Revisit (Settled for the MVP) @@ -2560,7 +2571,7 @@ Reversibility: a dedicated contract can still be designed if attachment gaps pro ### 2026-10-08 — A migrated load that changes `campaignVersion` logs a migration entry (red-team F1) Context: red-team F1 (`design/redteam/2026-10-08-10-design.md`, BLOCKING). `20-contract.md` §10.2's fallback migration restamps `campaignVersion` and leaves `actionLog` untouched. A `formatVersion: 2` save whose last content entry ends at `e2`, loaded on a host that no longer archives `e2` and migrated onto `e3`, therefore became a state whose last `to` disagreed with its `campaignVersion`. C26 and `deserialize` refuse that as `invalid_state` on the next hydration or re-save. `replayCompatible: false` cannot exempt it, because that flag lives on the save and the session record, not on `GameState`. -Chosen: a new log member, `LoggedMigration { seq, system: "migration", from, to }`. A migration that changes `campaignVersion` on a log that already carries an epoch entry appends one in the same step that restamps the version; that state is already `formatVersion: 2`. "Epoch entry" names a content entry or a migration entry. C26 is restated over epoch entries, C22 says which version changes append one, and the derived starting version is the first epoch entry's `from`. A migration over a log with no epoch entry appends nothing and keeps `formatVersion: 1`, so Revision 1's promise holds unchanged: a game that never adopts serializes byte-identically to one written before content epochs, migrated or not. A kind-shape-only migration appends nothing. A migration entry is never replayed. `branchSession` refuses a log carrying one, which is the existing `replayCompatible: false` refusal made readable from the state where the entry exists. Capture refuses every `replayCompatible: false` session, and the §14 runner fails a fixture carrying a migration entry. The scope was first drafted as every version-changing migration and narrowed on review, with the user's sign-off. Retention and the session string table count the log only from the last migration entry's `to`. Idempotency is unchanged: a second load finds no mismatch and appends nothing. The design is `16-content-epochs.md` §3.5; the contract is `20-contract.md` C3, C22, C26, §2, §4 `deserialize`, §7.4 and §10.2. +Chosen: a new log member, `LoggedMigration { seq, system: "migration", from, to }`. A migration that changes `campaignVersion` on a log that already carries an epoch entry appends one in the same step that restamps the version; that state is already `formatVersion: 2`. "Epoch entry" names a content entry or a migration entry. C26 is restated over epoch entries, C22 says which version changes append one, and the derived starting version is the first epoch entry's `from`. A migration over a log with no epoch entry appends nothing and keeps `formatVersion: 1`, so Revision 1's promise holds unchanged: a game that never adopts serializes byte-identically to one written before content epochs, migrated or not. A kind-shape-only migration appends nothing. A migration entry is never replayed. `branchSession` refuses a log carrying one, which is the existing `replayCompatible: false` refusal made readable from the state where the entry exists. Capture records none, and the §14 runner fails a fixture carrying one. The scope was first drafted as every version-changing migration and narrowed on review, with the user's sign-off. Retention and the session string table count the log only from the last migration entry's `to`. Idempotency is unchanged: a second load finds no mismatch and appends nothing. The design is `16-content-epochs.md` §3.5; the contract is `20-contract.md` C3, C22, C26, §2, §4 `deserialize`, §7.4 and §10.2. Rejected: **Exempting migrated states from C26.** `deserialize` cannot see the exemption, and the integrity check would be removed rather than satisfied. **Appending for every version-changing migration.** One unconditional rule, and the log would name every epoch a game ran on. But every migrated save of a host that never adopts would become `formatVersion: 2`, breaking the byte-identical promise and stranding those saves if the host rolls its engine back. What it buys is a truthful starting version on logs that are never replayed: under the chosen scope, a migrated log with no epoch entry derives the epoch it migrated onto, which nothing reads for replay, and the strings and retention rules read correctly. **Reusing `LoggedContent`.** Replay would treat the entry as an adoption and try to repeat a move whose source epoch the host could not resolve. Reversibility: design only until content epochs are built. `src/engine/src/core/persistence/envelope.ts` restamps without an entry, which stays correct for every log with no epoch entry; the conditional append lands with the content-epochs implementation. After that the entry is persisted in migrated saves that had adopted. Widening the rule to every migration later is possible but changes the format of saves from hosts that never adopt. diff --git a/design/OPEN-QUESTIONS.md b/design/OPEN-QUESTIONS.md index 80878a87..7fc864db 100644 --- a/design/OPEN-QUESTIONS.md +++ b/design/OPEN-QUESTIONS.md @@ -14,4 +14,6 @@ ### Found by the content-epochs design +### Found by the content-epochs red-team revision + ## 3. Judgement Calls to Revisit (Settled for the MVP) diff --git a/docs/docs/engine/04-core.md b/docs/docs/engine/04-core.md index 8d6afcc9..7b067ffb 100644 --- a/docs/docs/engine/04-core.md +++ b/docs/docs/engine/04-core.md @@ -2077,8 +2077,8 @@ load the same way: with no epoch entry appends nothing and keeps `formatVersion: 1`, so a host that never adopts writes the saves it wrote before content epochs. A migration that changes only `kindVersion` restamps no version and appends nothing. A migration entry is never replayed: - `branchSession` refuses a log carrying one (§7.4), capture refuses every - `replayCompatible: false` session, and §14's runner fails a fixture that carries one. + `branchSession` refuses a log carrying one (§7.4), capture records none, and §14's runner + fails a fixture that carries one. - **A migration is idempotent against its own output.** Migrating a save, saving it, and migrating again — which is what happens whenever a player loads, plays nothing, and saves, or whenever a fixture is regenerated — must reach a canonically identical `kindState`. The diff --git a/docs/docs/engine/08-session-capture.md b/docs/docs/engine/08-session-capture.md index 1e59bbae..421063e3 100644 --- a/docs/docs/engine/08-session-capture.md +++ b/docs/docs/engine/08-session-capture.md @@ -63,9 +63,8 @@ with no translation step and can be promoted into the corpus without conversion A session that adopted new content (16) is captured with an `AdoptionSubmission` (07 §2) at each content entry's position. That is not a new format — the union is 07's — and an adoption carries no caller input to filter: its only value is a version the store chose from the host's -channel, never one a client sent. A session whose record is `replayCompatible: false` — every -migrated session, including each whose log carries a migration entry (16 §3.5) — has no fixture -form: a migration is not a step replay can perform, so it is not captured. +channel, never one a client sent. A session whose log carries a migration entry (16 §3.5) has no +fixture form — a migration is not a step replay can perform — and is not captured. --- diff --git a/docs/docs/engine/16-content-epochs.md b/docs/docs/engine/16-content-epochs.md index 69c884f4..73d69d07 100644 --- a/docs/docs/engine/16-content-epochs.md +++ b/docs/docs/engine/16-content-epochs.md @@ -170,8 +170,7 @@ can perform — the epoch it left is, by construction, one this host could not r record stays the authority, and the entry makes the refusal readable from the state where one exists. - **`branchSession` refuses** such a session, as it already refuses `replayCompatible: false`. -- **Capture refuses every `replayCompatible: false` session** (08 §2), so no fixture carries a - migration entry. +- **Capture has no submission for one** (08 §2), so no fixture carries one. - **Only the epochs after the last migration entry are live.** Retention (§6) and the session's string table (§5.7) count the log from there: what came before is history the session can no longer replay, and nothing resolves through it. diff --git a/docs/docs/engine/OPEN-QUESTIONS.md b/docs/docs/engine/OPEN-QUESTIONS.md index ef24c86a..385de15c 100644 --- a/docs/docs/engine/OPEN-QUESTIONS.md +++ b/docs/docs/engine/OPEN-QUESTIONS.md @@ -594,6 +594,17 @@ neither. Each is a gap in attachments, not a reason for a second extension contr extension, above). **Revisit** when Adventures moves its extensions onto attachments, which is what retires its merge. +### Found by the content-epochs red-team revision + +**Capture does not refuse a migrated session that carries no migration entry.** 08 refuses a +session whose log carries a migration entry, but nothing in 08 checks `replayCompatible`. A +session migrated across campaign versions before it ever adopted, or migrated in kind shape only, +carries no entry, so capture would turn it into a fixture whose actions were taken on content +the fixture does not name. The gap predates content epochs. Narrowing F1's migration entry to +epoch-bearing logs (2026-10-08) left it as it was rather than opening it. The likely fix is one +line in 08: refuse every `replayCompatible: false` session. It was kept out of the F1 revision +because it is a separate unit. **Revisit** before capture is built. + --- ## 3. Judgement Calls to Revisit (Settled for the MVP) diff --git a/docs/docs/guide.md b/docs/docs/guide.md index 6899ee82..5579ac81 100644 --- a/docs/docs/guide.md +++ b/docs/docs/guide.md @@ -4,7 +4,7 @@ sidebar_position: 1 sidebar_label: Developer Guide --- - + > Generated from `design/` by `/make-human-docs`. Do not edit by hand — edit the > design docs and regenerate. `/reconcile` reports when this has gone stale.