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 @@ -340,7 +340,7 @@ Out of scope: Any shipped kind's `adoptContent` (S132 for story-graph; simulatio

## S131 — A Host Publishes New Content While Running

Status: todo
Status: done
Delivers: A host can publish a new build of its content without restarting. The engine
validates every campaign before accepting it, refuses to let one version name stand for
two different contents, and offers the latest publication to every session that asks.
Expand Down
20 changes: 20 additions & 0 deletions design/90-decisions.md
Original file line number Diff line number Diff line change
Expand Up @@ -2611,6 +2611,26 @@ Context: S130.5 says the harness replays each content entry through `adoptConten
Chosen: the harness fails a fixture whose content entry's `from` is not the replay's `campaignVersion`, naming both versions, before it calls `adoptContent`. The internal harness also gains `traceFixture`, which returns `serialize()` after creation and after every entry; `runFixture` serializes once, so long-horizon fixtures do not pay for a trace they do not compare.
Rejected: **Trusting `from`.** The fixture would pass while the replayed log disagreed with it, and C21's byte-identical comparison would fail with no message saying why.

### 2026-10-09 — S131: `publish` runs each kind's `validateCampaign`, and an unregistered kind refuses with `unknown_kind`
Context: 16 §4.3 and S131.2 say `publish` re-runs every campaign's `validateCampaign` against `registry.strings`. Neither says what happens to a campaign whose `kindId` names no kind in the archive's `kinds`, which has no `validateCampaign` to run.
Chosen: such a campaign refuses the publication with `unknown_kind`, the error `buildValidatedContentRegistry` gives it. The core-owned Tier 1 checks (`validateCoreOwnedFields`) are not re-run: they ran when the registry was built, and nothing between building and publishing can change them.
Rejected: **Skipping the campaign.** The archive would then serve content that was never validated, which C23 forbids. **Re-running the whole registry build.** `publish` takes a built registry, not its sources, so it has no inputs to rebuild from.

### 2026-10-09 — S131: a campaign's `includes` count toward its digest only when present and non-empty
Context: 16 §3.4 defines content identity as 11 §6's campaign digest "and `includes` when present". 11 §6, and `packVersion`, which implements it, include `includes` only when it is present *and non-empty*.
Chosen: the archive follows 11 §6 exactly, since §3.4 defines its digest as that one. A campaign with `includes: []` digests the same as one with no `includes`.
Rejected: **Digesting an empty list.** Two campaigns that play identically would then have different identities, and §3.4 says it reuses 11 §6's digest rather than defining a second one.

### 2026-10-09 — S131: a successful republish of an older epoch becomes the latest publication
Context: S131.5 makes `latest()` the last publication that succeeded, and S131.3 makes an identical republish a success that adds no epochs. Together, republishing an older registry moves the channel back to it.
Chosen: that is the behaviour. A host rolls back by republishing the registry it wants offered, and the archive records nothing new because nothing new was published. Sessions already on a newer epoch stay there; the channel only answers what a session should move to next.
Rejected: **Ignoring a republish that adds no epochs.** A host would then have no way to roll the channel back without a durable archive of its own.

### 2026-10-09 — S131: a key resolves to the registry it was first published in
Context: a publication stores one frozen registry. A later publication can carry an identical copy of a held key next to new content for other campaigns. 16 §3.4 says one key names one content, but not which publication's registry `resolve` answers for that key.
Chosen: the first. A held key is never rewritten, so what `resolve` returns for a key never changes. That key's campaign and the whole string table are identical by digest in every later copy anyway, so only the *other* campaigns in the returned registry can differ, and the engine reads only the state's own campaign from it.
Rejected: **Repointing the key to the newest copy.** The registry an epoch resolves to would then change under a running session for no content reason, which is the drift C23 exists to rule out.

### 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 @@ -334,7 +334,7 @@ Out of scope: Any shipped kind's `adoptContent` (S132 for story-graph; simulatio

## S131 — A Host Publishes New Content While Running

Status: todo
Status: done
Delivers: A host can publish a new build of its content without restarting. The engine
validates every campaign before accepting it, refuses to let one version name stand for
two different contents, and offers the latest publication to every session that asks.
Expand Down
23 changes: 23 additions & 0 deletions src/engine/src/campaigns/bulgaria-bureaucracy.archive.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { describe, it, expect } from "vitest";
import { createContentArchive } from "../core/registry/archive.js";
import { buildValidatedContentRegistry } from "../core/validation/tiered.js";
import type { KindRegistry } from "../core/kernel/types.js";
import { storyGraphKind } from "../kinds/story-graph/kind.js";
import { buildBulgariaBureaucracyCampaign } from "./bulgaria-bureaucracy.js";

// S131.1 against real content: the archive validates, digests and freezes a full campaign.
describe("S131.1 — the content archive holds the real campaign", () => {
it("accepts its validated registry, resolves it at its version, and republishes it as a no-op", () => {
const built = buildBulgariaBureaucracyCampaign();
if (!built.ok || !built.value) throw new Error("expected the real campaign to build");
const kinds = { "story-graph": storyGraphKind } as unknown as KindRegistry;
const validated = buildValidatedContentRegistry([built.value], kinds);
if (!validated.ok || !validated.value) throw new Error("expected the real campaign to validate");

const archive = createContentArchive({ kinds, initial: validated.value });
const { id, version } = built.value.campaign;

expect(archive.resolve(id, version)?.campaigns.get(id)?.content).toEqual(built.value.campaign.content);
expect(archive.publish(validated.value)).toMatchObject({ ok: true, value: [] });
});
});
7 changes: 7 additions & 0 deletions src/engine/src/core/kernel/reasons.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,10 @@ export type ReasonCode = string;
* target epoch is another kind's, when the kind declares no `adoptContent`, and when the kind
* refuses or throws. Base rather than kind-owned because the session store reads a refusal
* without knowing which kind produced it, and the first two are decided before any kind runs.
*
* `content_version_conflict` was added with the content archive (16 §3.4, §4.3) — `publish`
* refuses a registry that names a held `(campaignId, campaignVersion)` with different content.
* Base because the archive is core-owned and the identity it protects is the envelope's.
*/
export const BASE_REASON_CODES = [
"action_not_available",
Expand Down Expand Up @@ -157,6 +161,8 @@ export const BASE_REASON_CODES = [
"content_kind_changed",
"content_not_adoptable",
"content_incompatible",
// content epochs (16 §3.4) — publication
"content_version_conflict",
] as const;

export type BaseReasonCode = (typeof BASE_REASON_CODES)[number];
Expand Down Expand Up @@ -214,6 +220,7 @@ const CORE_REASON_TEXT: Readonly<Record<BaseReasonCode, string>> = {
content_kind_changed: "The new version of this campaign is a different kind of game, so this session stays on its version.",
content_not_adoptable: "This kind of game can't move a session onto new content, so it stays on its version.",
content_incompatible: "This session can't move onto the new version of its campaign, so it stays on the version it started.",
content_version_conflict: "This version of the campaign was already published with different content.",
};

/** `core.reason.<code>` → its shipped default-English message, for every base code. */
Expand Down
Loading
Loading