Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
157 changes: 135 additions & 22 deletions design/10-design.md

Large diffs are not rendered by default.

6 changes: 5 additions & 1 deletion design/16-content-epochs.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,16 @@

## 3. Data Model

### 3.1 The log carries content entries
### 3.1 The log carries epoch entries

### 3.2 Sequence numbers and randomness

### 3.3 Format version

### 3.4 The archive

### 3.5 A migrated load leaves a migration entry

## 4. Module Boundaries

### 4.1 The engine resolves by state
Expand All @@ -40,6 +42,8 @@

### 5.6 Replay and branching

### 5.7 Strings across an adoption

## 6. Distribution

## 7. Failure Modes
Expand Down
110 changes: 80 additions & 30 deletions design/20-contract.md

Large diffs are not rendered by default.

34 changes: 32 additions & 2 deletions design/90-decisions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down Expand Up @@ -603,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)
Expand Down Expand Up @@ -2556,3 +2568,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` 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.

### 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.
2 changes: 2 additions & 0 deletions design/OPEN-QUESTIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
11 changes: 8 additions & 3 deletions docs/docs/engine/03-story-graph-kind.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand Down
Loading
Loading