diff --git a/design/90-decisions.md b/design/90-decisions.md index a651af1..7b1a599 100644 --- a/design/90-decisions.md +++ b/design/90-decisions.md @@ -2229,3 +2229,16 @@ Context: The kit's `v2026.10.07` removed `/track`, `Test-DesignState.ps1` and `U Chosen: Delete `design/state/` and `design/state-index.md`; remove the *Writing a design-state record* section from `AGENTS.md` and the orphaned-mirror sentence from `CLAUDE.md`. Earlier decision-log entries and `30-slices.md` text that name them are history and stay. Rejected: Keep them frozen — a stale mirror of issue state that can only mislead. Reversibility: cheap. Restoring is a checkout of the parent commit's `design/state*`. + +### 2026-10-07 — Content crosses a campaign boundary by composition at load, never by traversal (#293, #292) +Context: #293 asked what a session becomes when a choice leads into another campaign's content. `GameState.campaignId` is singular (`src/engine/src/core/kernel/types.ts:44`), the save envelope rejects a `campaignId` mismatch as `invalid_state` (`src/engine/src/core/persistence/envelope.ts:165`), `StoryGraphKindState` is one flat record with no frames, and every `adventure-builder.ts` campaign declares the same `route`/`preparation`/`connections`/`pressure` variables, so carrying one campaign's state into another's nodes would reinterpret silently rather than fail. #292 asked for node-granular content deltas, which `resolvePacks` cannot express because the core cannot interpret `Campaign.content`. +Chosen: **Composition at registry build.** A host campaign *includes* another campaign as a module, pinned by `{id, version}`. A kind-owned, optional, pure `Kind` operation composes the included content into the host before the existing tiered validation runs, so at runtime there is one campaign and one session, and `GameState`, the save envelope and every host are unchanged. The boundary is specified: +- **Variables.** A module's variables, and its node ids, live under the include's alias prefix (`::`). The prefix is what makes the shared-name collision impossible; existing campaigns are not renamed. A module declares its inputs and outputs, and the include binds them to host variables on the entry and exit edges as ordinary consequences. +- **`visitedCounts`.** Keyed by the prefixed node id, so a module's counts are its own by construction. +- **Endings.** A module's endings become exits back into the host; an include maps each exit to a host node, or to a host ending. A module never terminates the host run on its own. +- **Achievements.** A module's achievements are prefixed and credited to the host campaign, the campaign the session is in. They do not credit the module's standalone campaign. +- **Identity.** The include pins the module's `{id, version}` and composition refuses a mismatch, so the host's published version determines the composed content. Under content packs the resolution digest already covers it; `computeResolutionId` is unchanged. +- **Packs.** A pack can add an include to a campaign it does not own; that is the third-party side-quest case, and it is the structural delta #292 asked for. Text deltas stay the per-key string fold. +Contract work, in `design/` before any code: the `Kind` composition operation, the story-graph include and module declarations, and the reason codes composition raises. +Rejected: **Not supported** (author side quests as nodes inside one campaign) — overruled by the owner; it leaves #292's third-party case without an answer. **Jump** — leaves the first campaign's variables and `visitedCounts` in a session now claiming to be the second. **Call/return frames** — a `StoryGraphKindState` shape change and a save migration; kept as the extension if a target must ever be chosen at runtime, and a module's entry/exit/inputs/outputs are exactly the interface a frame would call. **Linked sessions** — every progress, standings and achievement surface would have to learn session parents. **Profile carry-over** — fits sequels, not a side quest inside a run; it needs no decision to use. +Reversibility: cheap now — no code or contract exists yet. Expensive once composed campaigns are published, because prefixed ids become published ids (C18).