From 29aa9985fb053933c43fe8b671d1b51908ef588e Mon Sep 17 00:00:00 2001 From: Ben Richards Date: Fri, 9 Oct 2026 13:25:59 +0300 Subject: [PATCH] =?UTF-8?q?S131=20=E2=80=94=20A=20host=20publishes=20new?= =?UTF-8?q?=20content=20while=20running?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- design/30-slices.md | 2 +- design/90-decisions.md | 20 ++ docs/docs/engine/TODO.md | 2 +- .../bulgaria-bureaucracy.archive.test.ts | 23 ++ src/engine/src/core/kernel/reasons.ts | 7 + src/engine/src/core/registry/archive.test.ts | 297 ++++++++++++++++++ src/engine/src/core/registry/archive.ts | 199 ++++++++++++ src/engine/src/index.ts | 3 + 8 files changed, 551 insertions(+), 2 deletions(-) create mode 100644 src/engine/src/campaigns/bulgaria-bureaucracy.archive.test.ts create mode 100644 src/engine/src/core/registry/archive.test.ts create mode 100644 src/engine/src/core/registry/archive.ts diff --git a/design/30-slices.md b/design/30-slices.md index df796441..081592e2 100644 --- a/design/30-slices.md +++ b/design/30-slices.md @@ -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. diff --git a/design/90-decisions.md b/design/90-decisions.md index 814d4769..165df6df 100644 --- a/design/90-decisions.md +++ b/design/90-decisions.md @@ -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. diff --git a/docs/docs/engine/TODO.md b/docs/docs/engine/TODO.md index 88561526..a70ac530 100644 --- a/docs/docs/engine/TODO.md +++ b/docs/docs/engine/TODO.md @@ -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. diff --git a/src/engine/src/campaigns/bulgaria-bureaucracy.archive.test.ts b/src/engine/src/campaigns/bulgaria-bureaucracy.archive.test.ts new file mode 100644 index 00000000..8716bc2c --- /dev/null +++ b/src/engine/src/campaigns/bulgaria-bureaucracy.archive.test.ts @@ -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: [] }); + }); +}); diff --git a/src/engine/src/core/kernel/reasons.ts b/src/engine/src/core/kernel/reasons.ts index 6db747b5..7d998b86 100644 --- a/src/engine/src/core/kernel/reasons.ts +++ b/src/engine/src/core/kernel/reasons.ts @@ -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", @@ -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]; @@ -214,6 +220,7 @@ const CORE_REASON_TEXT: Readonly> = { 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.` → its shipped default-English message, for every base code. */ diff --git a/src/engine/src/core/registry/archive.test.ts b/src/engine/src/core/registry/archive.test.ts new file mode 100644 index 00000000..5ad388e0 --- /dev/null +++ b/src/engine/src/core/registry/archive.test.ts @@ -0,0 +1,297 @@ +import { describe, it, expect } from "vitest"; +import { createContentArchive } from "./archive.js"; +import { createEngine } from "../kernel/engine.js"; +import type { AdvanceResult, InitialStateResult, Kind, KindRegistry, SceneBody } from "../kernel/types.js"; +import { BASE_REASON_CODES, CORE_REASON_MESSAGES } from "../kernel/reasons.js"; +import type { Campaign, ContentRegistry } from "./types.js"; +import type { ValidationResult } from "../validation/types.js"; +// S131.7: the archive and channel are reachable from the package root. +import { + createContentArchive as rootCreateContentArchive, + type ContentArchive as RootContentArchive, + type ContentChannel as RootContentChannel, + type ContentScope as RootContentScope, + type EpochRef as RootEpochRef, +} from "../../index.js"; + +interface Counter { + counter: number; +} + +interface TestContent { + steps: number[]; + invalid?: boolean; +} + +// A kind whose validator refuses `content.invalid`, and whose scene reads the campaign the +// engine resolved for the state — so a game is visibly playing the content of its own epoch. +const testKind: Kind = { + id: "story-graph", + version: "1.0.0", + reasonCodes: [], + reasonMessages: new Map(), + eventNames: [], + initialState: (): InitialStateResult => ({ state: { counter: 0 }, status: "active", changes: [], messages: [] }), + availableActions: () => [{ id: "increment", labelKey: "test.increment", available: true }], + scene: (state, ctx): SceneBody => ({ + textKey: "test.scene", + text: `counter=${state.counter} steps=${(ctx.campaign.content as TestContent).steps.join(",")}`, + }), + advance: (state, _actionId, _params, ctx): AdvanceResult => ({ + state: { counter: state.counter + ((ctx.campaign.content as TestContent).steps[0] ?? 0) }, + status: "active", + changes: [], + messages: [], + }), + project: (state) => ({ counter: state.counter }), + validateState: () => true, + outcome: () => ({ terminal: false, terminalId: null }), + validateCampaign: (campaign): ValidationResult => { + const invalid = (campaign.content as TestContent).invalid === true; + return { + ok: !invalid, + errors: invalid ? [{ code: "missing_string_key", messageKey: "core.reason.missing_string_key", path: campaign.id }] : [], + warnings: [], + }; + }, +}; + +const kinds = { "story-graph": testKind } as unknown as KindRegistry; + +function campaign(version: string, content: TestContent = { steps: [1] }, id = "test-campaign"): Campaign { + return { id, kindId: "story-graph", version, titleKey: "test.title", content }; +} + +function registry(campaigns: Campaign[], strings: [string, string][] = [["test.title", "Test"]]): ContentRegistry { + return { campaigns: new Map(campaigns.map((c) => [c.id, c])), strings: new Map(strings) }; +} + +describe("S131.1 — createContentArchive", () => { + it("resolves every campaign of `initial` at its version", () => { + const initial = registry([campaign("1"), campaign("7", { steps: [2] }, "other")]); + const archive = createContentArchive({ kinds, initial }); + + expect(archive.resolve("test-campaign", "1")?.campaigns.get("test-campaign")?.version).toBe("1"); + expect(archive.resolve("other", "7")?.campaigns.get("other")?.content).toEqual({ steps: [2] }); + expect(archive.resolve("test-campaign", "2")).toBeUndefined(); + expect(archive.resolve("missing", "1")).toBeUndefined(); + }); + + it("throws when `initial` fails validation", () => { + expect(() => createContentArchive({ kinds, initial: registry([campaign("1", { steps: [1], invalid: true })]) })).toThrow( + /initial registry fails validation: missing_string_key \(test-campaign\)/, + ); + }); + + it("throws when a campaign's kind is not registered", () => { + const orphan = { ...campaign("1"), kindId: "world-graph" } as Campaign; + expect(() => createContentArchive({ kinds, initial: registry([orphan]) })).toThrow(/unknown_kind \(world-graph\)/); + }); +}); + +describe("S131.2 — publish validates every campaign", () => { + it("refuses with the validator's errors, stores nothing and leaves latest unchanged", () => { + const archive = createContentArchive({ kinds, initial: registry([campaign("1")]) }); + const before = archive.latest(); + + const result = archive.publish(registry([campaign("2", { steps: [1], invalid: true })])); + + expect(result.ok).toBe(false); + expect(result.value).toBeUndefined(); + expect(result.errors).toEqual([{ code: "missing_string_key", messageKey: "core.reason.missing_string_key", path: "test-campaign" }]); + expect(archive.resolve("test-campaign", "2")).toBeUndefined(); + expect(archive.latest()).toBe(before); + }); + + it("validates against the publication's own strings", () => { + const seen: ReadonlyMap[] = []; + const watching = { "story-graph": { ...testKind, validateCampaign: (c: Campaign, s: ReadonlyMap) => (seen.push(s), testKind.validateCampaign(c, s)) } } as unknown as KindRegistry; + const archive = createContentArchive({ kinds: watching, initial: registry([campaign("1")]) }); + const next = registry([campaign("2")], [["test.title", "Test 2"]]); + + archive.publish(next); + + expect(seen.at(-1)).toBe(next.strings); + }); +}); + +describe("S131.3 — one key names one content", () => { + it("refuses a held key whose content differs with content_version_conflict, storing nothing", () => { + const archive = createContentArchive({ kinds, initial: registry([campaign("1")]) }); + const before = archive.latest(); + + const result = archive.publish(registry([campaign("1", { steps: [5] }), campaign("1", { steps: [1] }, "fresh")])); + + expect(result.ok).toBe(false); + expect(result.errors).toEqual([ + { + code: "content_version_conflict", + messageKey: "core.reason.content_version_conflict", + path: "test-campaign", + details: { campaignVersion: "1" }, + }, + ]); + expect(archive.resolve("test-campaign", "1")?.campaigns.get("test-campaign")?.content).toEqual({ steps: [1] }); + // The new key in the refused publication is not stored either. + expect(archive.resolve("fresh", "1")).toBeUndefined(); + expect(archive.latest()).toBe(before); + }); + + it("a change to one string under an unchanged version is a conflict", () => { + const archive = createContentArchive({ kinds, initial: registry([campaign("1")], [["test.title", "Test"], ["test.extra", "a"]]) }); + + const result = archive.publish(registry([campaign("1")], [["test.title", "Test"], ["test.extra", "b"]])); + + expect(result.errors.map((e) => e.code)).toEqual(["content_version_conflict"]); + }); + + it("a string added under an unchanged version is a conflict too", () => { + const archive = createContentArchive({ kinds, initial: registry([campaign("1")]) }); + const result = archive.publish(registry([campaign("1")], [["test.title", "Test"], ["test.new", "n"]])); + expect(result.errors.map((e) => e.code)).toEqual(["content_version_conflict"]); + }); + + it("an identical republish succeeds and adds no epochs, whatever order its strings are in", () => { + const archive = createContentArchive({ kinds, initial: registry([campaign("1")], [["a.one", "1"], ["b.two", "2"]]) }); + + const result = archive.publish(registry([campaign("1")], [["b.two", "2"], ["a.one", "1"]])); + + expect(result).toEqual({ ok: true, value: [], errors: [], warnings: [] }); + }); + + it("migrateState is not content: a republish that differs only in it is identical", () => { + const archive = createContentArchive({ kinds, initial: registry([campaign("1")]) }); + const withMigration: Campaign = { ...campaign("1"), migrateState: (s) => ({ ok: true, value: s, errors: [], warnings: [] }) }; + expect(archive.publish(registry([withMigration]))).toMatchObject({ ok: true, value: [] }); + }); + + it("a new key returns its EpochRef, and a held identical key in the same publication does not", () => { + const archive = createContentArchive({ kinds, initial: registry([campaign("1"), campaign("1", { steps: [3] }, "other")]) }); + + const result = archive.publish(registry([campaign("2", { steps: [9] }), campaign("1", { steps: [3] }, "other")])); + + expect(result).toEqual({ ok: true, value: [{ campaignId: "test-campaign", campaignVersion: "2" }], errors: [], warnings: [] }); + expect(archive.resolve("test-campaign", "1")?.campaigns.get("test-campaign")?.content).toEqual({ steps: [1] }); + expect(archive.resolve("test-campaign", "2")?.campaigns.get("test-campaign")?.content).toEqual({ steps: [9] }); + }); +}); + +describe("S131.4 — what the archive serves is a deep-frozen copy", () => { + it("mutating the registry after publish changes nothing resolve answers", () => { + const archive = createContentArchive({ kinds, initial: registry([campaign("1")]) }); + const content: TestContent = { steps: [4] }; + const next = registry([campaign("2", content)]); + archive.publish(next); + + content.steps.push(99); + (next.campaigns as Map).set("test-campaign", campaign("2", { steps: [0] })); + (next.campaigns as Map).set("late", campaign("2", { steps: [0] }, "late")); + (next.strings as Map).set("test.title", "Changed"); + + const served = archive.resolve("test-campaign", "2")!; + expect(served.campaigns.get("test-campaign")?.content).toEqual({ steps: [4] }); + expect(served.campaigns.has("late")).toBe(false); + expect(served.strings.get("test.title")).toBe("Test"); + }); + + it("refuses mutation of what it serves", () => { + const archive = createContentArchive({ kinds, initial: registry([campaign("1")]) }); + const served = archive.resolve("test-campaign", "1")!; + const held = served.campaigns.get("test-campaign")!; + + expect(() => (held.content as TestContent).steps.push(2)).toThrow(TypeError); + expect(() => { + (held as { version: string }).version = "9"; + }).toThrow(TypeError); + expect(() => (served.campaigns as Map).set("x", held)).toThrow(TypeError); + expect(() => (served.strings as Map).delete("test.title")).toThrow(TypeError); + expect(() => (served.strings as Map).clear()).toThrow(TypeError); + expect(archive.resolve("test-campaign", "1")?.campaigns.get("test-campaign")?.content).toEqual({ steps: [1] }); + }); + + it("keeps migrateState by reference", () => { + const migrateState = (s: unknown) => ({ ok: true, value: s, errors: [], warnings: [] }); + const archive = createContentArchive({ kinds, initial: registry([{ ...campaign("1"), migrateState }]) }); + + expect(archive.resolve("test-campaign", "1")?.campaigns.get("test-campaign")?.migrateState).toBe(migrateState); + }); + + it("keeps the registry's resolution", () => { + const archive = createContentArchive({ kinds, initial: { ...registry([campaign("1")]), resolution: "abc" } }); + expect(archive.resolve("test-campaign", "1")?.resolution).toBe("abc"); + }); +}); + +describe("S131.5 — latest and the channel", () => { + const scope = { campaignId: "test-campaign", sessionId: "s1" }; + + it("latest is the last publication that succeeded, and current answers its version for every scope", () => { + const archive = createContentArchive({ kinds, initial: registry([campaign("1")]) }); + expect(archive.current(scope)).toBe("1"); + + archive.publish(registry([campaign("2")])); + expect(archive.latest().campaigns.get("test-campaign")?.version).toBe("2"); + expect(archive.current(scope)).toBe("2"); + expect(archive.current({ ...scope, sessionId: "s2", profileId: "p1" })).toBe("2"); + + // A refusal leaves the channel where it was. + archive.publish(registry([campaign("3", { steps: [1], invalid: true })])); + expect(archive.current(scope)).toBe("2"); + + // An identical republish of an older epoch is still a success, so it becomes the latest. + archive.publish(registry([campaign("1")])); + expect(archive.current(scope)).toBe("1"); + }); + + it("answers undefined for a campaign the latest publication does not hold", () => { + const archive = createContentArchive({ kinds, initial: registry([campaign("1")]) }); + archive.publish(registry([campaign("1", { steps: [1] }, "other")])); + + expect(archive.current(scope)).toBeUndefined(); + // Still resolvable: the archive never evicts. + expect(archive.resolve("test-campaign", "1")).toBeDefined(); + }); +}); + +describe("S131.6 — an engine over a content archive keeps playing an older epoch (C22)", () => { + const ids = { newGameId: () => "g", newSeed: () => "seed" }; + + it("a game created on one epoch plays unchanged after a newer epoch is published", () => { + const initial = registry([campaign("1", { steps: [1] })]); + const archive = createContentArchive({ kinds, initial }); + const engine = createEngine({ kinds, registry: initial, archive, ids }); + const control = createEngine({ kinds, registry: initial, ids }); + + const created = engine.createGame({ campaignId: "test-campaign" }).value!; + const reference = control.createGame({ campaignId: "test-campaign" }).value!; + + expect(archive.publish(registry([campaign("2", { steps: [10] })])).ok).toBe(true); + + const played = engine.submitAction(created, "increment").value!; + const expected = control.submitAction(reference, "increment").value!; + expect(played.campaignVersion).toBe("1"); + expect(engine.serialize(played)).toBe(control.serialize(expected)); + expect(engine.scene(played).body.text).toBe("counter=1 steps=1"); + expect(engine.serialize(engine.deserialize(engine.serialize(played)).value!)).toBe(engine.serialize(played)); + + // The newer epoch is playable alongside it. + const newer = engine.createGame({ campaignId: "test-campaign" }, "2").value!; + expect(engine.scene(engine.submitAction(newer, "increment").value!).body.text).toBe("counter=10 steps=10"); + }); +}); + +describe("S131.7 — the base reason code and the package root", () => { + it("content_version_conflict is a base reason code with a message", () => { + expect(BASE_REASON_CODES).toContain("content_version_conflict"); + expect(CORE_REASON_MESSAGES.get("core.reason.content_version_conflict")).toMatch(/already published/); + }); + + it("the archive and its types are exported from the package root", () => { + const archive: RootContentArchive = rootCreateContentArchive({ kinds, initial: registry([campaign("1")]) }); + const channel: RootContentChannel = archive; + const scope: RootContentScope = { campaignId: "test-campaign", sessionId: "s" }; + const ref: RootEpochRef = { campaignId: "test-campaign", campaignVersion: "1" }; + expect(rootCreateContentArchive).toBe(createContentArchive); + expect(channel.current(scope)).toBe(ref.campaignVersion); + }); +}); diff --git a/src/engine/src/core/registry/archive.ts b/src/engine/src/core/registry/archive.ts new file mode 100644 index 00000000..c768cd37 --- /dev/null +++ b/src/engine/src/core/registry/archive.ts @@ -0,0 +1,199 @@ +/** + * The in-memory content archive — the reference archive and channel (16-content-epochs.md + * §3.4, §4.3, §5.1). + * + * Contract: `20-contract.md` §4 (`createContentArchive`, `ContentArchive`, `EpochRef`), the + * session-layer channel (`ContentChannel`, `ContentScope`), and C23. + * + * One key `(campaignId, campaignVersion)` names one content forever. `publish` validates every + * campaign, refuses a held key whose content differs (`content_version_conflict`), and stores + * deep-frozen copies, so nothing the archive serves can change after it validated it. It is + * append-only and never evicts; a host that needs durability or eviction supplies its own. + */ + +import { canonicalize as canonicalStringify, sha256Hex } from "subzerodev-data-json"; +import type { KindRegistry, ResolutionArchive } from "../kernel/types.js"; +import type { CommandResult } from "../kernel/reasons.js"; +import type { LocKey } from "../localization/types.js"; +import type { ValidationError, ValidationWarning } from "../validation/types.js"; +import type { Campaign, ContentRegistry } from "./types.js"; + +/** One epoch: a campaign at one version. */ +export interface EpochRef { + campaignId: string; + campaignVersion: string; +} + +/** Who a channel is answering for (16 §4.2). */ +export interface ContentScope { + campaignId: string; + sessionId: string; + /** Absent for an anonymous session. */ + profileId?: string; +} + +/** The session-layer port that offers a session a version (16 §4.2). */ +export interface ContentChannel { + /** The campaignVersion a session in this scope should be on, or undefined for no offer. + * Called under the session lock. A throw is read as no offer. */ + current(scope: ContentScope): string | undefined; +} + +/** The reference archive (16 §4.3): both archive and channel, in memory, append-only. */ +export interface ContentArchive extends ResolutionArchive, ContentChannel { + /** Validate every campaign against `registry.strings`, refuse a held key with a different + * digest (`content_version_conflict`), then store deep-frozen copies and make this the + * latest. Returns the epochs newly added; an identical republish adds none. */ + publish(registry: ContentRegistry): CommandResult; + /** The last publication that succeeded. Its channel answers this registry's version of + * the scope's campaign, for every scope. */ + latest(): ContentRegistry; +} + +/** + * 16 §3.4's content identity: 11 §6's canonical campaign digest — `id`, `kindId`, `version`, + * `titleKey`, `content`, and `includes` only when present and non-empty, exactly as + * `packVersion` reads them — plus the registry's full `strings` table, sorted by key. Never + * `migrateState`, which is code, not content, and which `canonicalStringify` rejects. + */ +function epochDigest(campaign: Campaign, strings: ReadonlyMap): string { + return sha256Hex( + canonicalStringify({ + campaign: { + id: campaign.id, + kindId: campaign.kindId, + version: campaign.version, + titleKey: campaign.titleKey, + content: campaign.content, + ...(campaign.includes && campaign.includes.length > 0 ? { includes: campaign.includes } : {}), + }, + strings: [...strings].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)), + }), + ); +} + +function refuseMutation(): never { + throw new TypeError("content archive: a published registry is frozen"); +} + +/** A frozen `Map` refuses `set`, `delete` and `clear`, which `Object.freeze` alone does not. */ +function lockMap(map: Map): ReadonlyMap { + Object.defineProperties(map, { + set: { value: refuseMutation }, + delete: { value: refuseMutation }, + clear: { value: refuseMutation }, + }); + return Object.freeze(map); +} + +function deepFreeze(value: T): T { + if (value === null || typeof value !== "object" || Object.isFrozen(value)) return value; + if (value instanceof Map) { + for (const [key, entry] of value) { + deepFreeze(key); + deepFreeze(entry); + } + lockMap(value); + return value; + } + for (const key of Reflect.ownKeys(value)) deepFreeze((value as Record)[key]); + return Object.freeze(value); +} + +/** Data is copied and frozen; `migrateState` is kept by reference (16 §4.3). */ +function frozenCampaign(campaign: Campaign): Campaign { + const { migrateState, ...data } = campaign; + const copy: Campaign = structuredClone(data); + if (migrateState) copy.migrateState = migrateState; + return deepFreeze(copy); +} + +function frozenRegistry(registry: ContentRegistry): ContentRegistry { + const campaigns = new Map(); + for (const [id, campaign] of registry.campaigns) campaigns.set(id, frozenCampaign(campaign)); + return Object.freeze({ + campaigns: lockMap(campaigns), + strings: lockMap(new Map(registry.strings)), + ...(registry.resolution === undefined ? {} : { resolution: registry.resolution }), + }); +} + +function validate(registry: ContentRegistry, kinds: KindRegistry): { errors: ValidationError[]; warnings: ValidationWarning[] } { + const errors: ValidationError[] = []; + const warnings: ValidationWarning[] = []; + for (const campaign of registry.campaigns.values()) { + const kind = kinds[campaign.kindId]; + if (!kind) { + errors.push({ code: "unknown_kind", messageKey: "core.reason.unknown_kind", path: campaign.kindId }); + continue; + } + const result = kind.validateCampaign(campaign, registry.strings); + errors.push(...result.errors); + warnings.push(...result.warnings); + } + return { errors, warnings }; +} + +interface HeldEpoch { + digest: string; + registry: ContentRegistry; +} + +/** + * The reference archive (20-contract.md §4). `initial` is its first publication — normally the + * engine's own registry — and an `initial` that fails validation throws, a construction error + * like every other one that section names. + */ +export function createContentArchive(options: { kinds: KindRegistry; initial: ContentRegistry }): ContentArchive { + const { kinds } = options; + // campaignId → campaignVersion → what that key names, forever. + const held = new Map>(); + let latest: ContentRegistry | undefined; + + function publish(registry: ContentRegistry): CommandResult { + const { errors, warnings } = validate(registry, kinds); + if (errors.length > 0) return { ok: false, errors, warnings }; + + // Digested before copying, and the copy is taken before anything is stored, so a refusal + // leaves the archive exactly as it was. + const added: { ref: EpochRef; digest: string }[] = []; + const conflicts: ValidationError[] = []; + for (const campaign of registry.campaigns.values()) { + const digest = epochDigest(campaign, registry.strings); + const existing = held.get(campaign.id)?.get(campaign.version); + if (!existing) { + added.push({ ref: { campaignId: campaign.id, campaignVersion: campaign.version }, digest }); + } else if (existing.digest !== digest) { + conflicts.push({ + code: "content_version_conflict", + messageKey: "core.reason.content_version_conflict", + path: campaign.id, + details: { campaignVersion: campaign.version }, + }); + } + } + if (conflicts.length > 0) return { ok: false, errors: conflicts, warnings }; + + const frozen = frozenRegistry(registry); + for (const { ref, digest } of added) { + const versions = held.get(ref.campaignId) ?? new Map(); + versions.set(ref.campaignVersion, { digest, registry: frozen }); + held.set(ref.campaignId, versions); + } + latest = frozen; + return { ok: true, value: added.map(({ ref }) => ref), errors: [], warnings }; + } + + const first = publish(options.initial); + if (!first.ok) { + const codes = first.errors.map((e) => (e.path === undefined ? e.code : `${e.code} (${e.path})`)).join(", "); + throw new Error(`createContentArchive: the initial registry fails validation: ${codes} (20-contract.md §4)`); + } + + return { + publish, + latest: () => latest!, + resolve: (campaignId, campaignVersion) => held.get(campaignId)?.get(campaignVersion)?.registry, + current: (scope) => latest!.campaigns.get(scope.campaignId)?.version, + }; +} diff --git a/src/engine/src/index.ts b/src/engine/src/index.ts index 60b72a0f..24950a32 100644 --- a/src/engine/src/index.ts +++ b/src/engine/src/index.ts @@ -22,6 +22,9 @@ export { resolveExperimentAssignments, resolvePacks, } from "./core/registry/packs.js"; +// The reference content archive and channel (16 §4.3) — host-side, like the packs above. +export { createContentArchive } from "./core/registry/archive.js"; +export type { ContentArchive, ContentChannel, ContentScope, EpochRef } from "./core/registry/archive.js"; export { createInMemorySessionStore, createSessionLayer, upsertAchievements, upsertTerminals } from "./core/session/store.js"; export { createInMemoryProfileStore } from "./core/session/profile-store.js"; export { TextClient } from "./clients/text/client.js";