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
2 changes: 1 addition & 1 deletion design/30-slices.md
Original file line number Diff line number Diff line change
Expand Up @@ -404,7 +404,7 @@ Out of scope: Simulation and world-graph adoption rules (the contract's *Unresol

## S133 — Running Sessions Pick Up New Content

Status: todo
Status: done
Delivers: A player's session moves onto newly published content between their moves: when
they submit an action or come back to the game. A refusal, or a host that fails to
answer, never costs them a move. Text they have already been shown never goes
Expand Down
20 changes: 20 additions & 0 deletions design/90-decisions.md
Original file line number Diff line number Diff line change
Expand Up @@ -2646,6 +2646,26 @@ Context: S132.4 adopts the Bureaucracy campaign onto a copy of itself extended b
Chosen: the S132.4 extension carries no `migrateState`, and a further test pins the real behaviour: the same extension, keeping the real `migrateState`, pins with `migration_failed`. Nothing in the campaign changed. Whether a content `migrateState` should pass through a version it does not migrate is authoring guidance, which this slice leaves out of scope.
Rejected: **Changing `migrateV1AdventureState` to pass other versions through.** That is a campaign change outside this slice. It would also change load-time behaviour, where a save from an unknown version is refused today.

### 2026-10-09 — S133: `createSession` resolves the kind and profile data from the offered epoch, and an unheld offer fails creation
Context: §7 says `createSession` asks the channel once and passes the answer to `createGame(config, version)`. Before content epochs, the store looked up the campaign in the registry to find the kind and its profile data. An offer can name a version the registry does not hold, or one the archive does not hold either.
Chosen: the store resolves the campaign through `engine.content.resolve(campaignId, version)` when the channel offers a version, and through the registry when it does not. An offer naming a version the archive does not hold fails creation with `unknown_campaign`, as `createGame` does. A channel answer that is not a string is read as no offer, like a throw.
Rejected: **Falling back to the registry's version on an unheld offer.** Creation is not adoption, so §7's "never fails a command" rule does not cover it, and a silent fallback would start the player on content the host did not choose.

### 2026-10-09 — S133: the profile fold receives the campaign of the epoch the action resolved on
Context: `submitAction` folds an accepted action's changes into the profile with `Kind.profileData.fold(current, campaign, changes)`. It passed the registry's campaign. Once a session can be on any held version, that campaign can be a different version from the one the action played.
Chosen: the fold receives the campaign of the state's own epoch before adoption, through `engine.content.resolve`, falling back to the registry's campaign only when the archive does not resolve it. The action's messages come from that epoch too.
Rejected: **The adopted epoch's campaign.** The changes being folded were produced against the old one.

### 2026-10-09 — S133: a failed adoption write at `resumeSession` restores the record, and a conflict also evicts it
Context: §7 says a failed adoption-only write at `resumeSession` restores the unadopted state, returns its scene, and the next adoption point offers again. A `concurrent_modification` means another writer committed past the cached record, which `submitAction` handles by evicting it (S122).
Chosen: both failures restore the cached record's blob, `updatedAt` and revision and return the unadopted scene. A conflict also evicts the record, so the next read goes back to persistence. Any other error still propagates. The `core.content.adopted` event has already been emitted when the write fails, because the engine emits it during `adoptContent`, so an event stream can show an adoption that never persisted.
Rejected: **Suppressing the event on a failed write.** The emitter belongs to the engine call, which completed. Withdrawing an emitted event would need a new event or a buffered emitter, which is a contract change for a case the next adoption point already repairs.

### 2026-10-09 — S133: `getStrings` skips an epoch the archive no longer resolves
Context: C27's union resolves every live epoch through `engine.content.resolve`. The reference archive is append-only, but a host-supplied archive may stop holding an epoch.
Chosen: an epoch that does not resolve contributes nothing, and the union of the rest is returned.
Rejected: **Throwing.** `getStrings` is a query, and a missing table for an older epoch should not stop a client from reading the current one. A session on an unresolvable current epoch already fails its other operations.

### 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.
Expand Down
2 changes: 1 addition & 1 deletion docs/docs/engine/TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -398,7 +398,7 @@ Out of scope: Simulation and world-graph adoption rules (the contract's *Unresol

## S133 — Running Sessions Pick Up New Content

Status: todo
Status: done
Delivers: A player's session moves onto newly published content between their moves: when
they submit an action or come back to the game. A refusal, or a host that fails to
answer, never costs them a move. Text they have already been shown never goes
Expand Down
5 changes: 5 additions & 0 deletions src/engine/src/core/composition/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
*/

import type { ContentRegistry } from "../registry/types.js";
import type { ContentChannel } from "../registry/archive.js";
import type { Engine, KindRegistry, ResolutionArchive } from "../kernel/types.js";
import type { Emitter } from "../observability/types.js";
import type { ProfileStore, SessionPersistence } from "../session/types.js";
Expand Down Expand Up @@ -99,4 +100,8 @@ export interface SessionHost {
/** §5.2. With `persistence`, the most sessions held in memory, evicted least recently
* used first. Omitted → unbounded. A positive integer, and refused without `persistence`. */
readonly sessionCacheLimit?: number;
/** 16 §4.2, §5.3. The version each session should be on, asked under the session lock at
* `createSession` and at every adoption point. Omitted → no session is ever offered
* content, and every operation behaves as it did before content epochs. */
readonly content?: ContentChannel;
}
Loading
Loading