A session belongs to exactly one campaign, and nothing decides what happens if content spans two
A proposal to let a scene offer a choice leading into another campaign's content — a side quest bridging Campaign A into Campaign B — has no answer today for what the session becomes at that moment, and the failure mode is silent rather than loud.
GameState.campaignId is singular. resolveSaveEnvelope cross-checks that the envelope's campaignId matches the embedded state's own and rejects a mismatch as invalid_state. Achievements are recorded per (campaign, achievement). Downstream hosts denormalize campaign_id per session and group player progress by it. Every one of those assumes a session sits in one campaign for its whole life.
The sharp part is not that a bridge would fail these checks. It is that it might not fail at all. Story-graph variables are per-campaign namespaces, but the committed campaigns do not use distinct names — every campaign built through adventure-builder.ts declares the same route, preparation, connections and pressure. Carrying Campaign A's variable state into Campaign B's nodes would not error; it would silently reinterpret A's values as B's, and the run would continue looking entirely normal while meaning something different. A crash would be better.
So the semantics have to be decided before any bridging content exists, not discovered by building one. The candidate shapes each imply different state:
- Jump — the session becomes Campaign B. Then what happens to A's variables, A's
visitedCounts, and A's unfinished ending set?
- Call and return — the session is in B temporarily and comes back. That implies a stack in
StoryGraphKindState, which does not have one.
- Nested session — B is a separate session linked to A. Cleanest for state isolation, but the player-facing "one continuous run" illusion then has to be assembled by the host.
- Not supported — bridging is expressed as content within one campaign, and cross-campaign reachability is a catalog concern rather than a traversal one.
This issue is the decision, not the implementation.
Done when
Agent instructions
Origin: evaluation of an external design handoff proposing cross-campaign side quests. That document explicitly left these semantics "deliberately not decided" — but its own first experiment would hit them immediately, which is why this is filed as a decision rather than folded into a spike.
Read first: src/engine/src/core/kernel/types.ts (GameState), src/engine/src/core/persistence/envelope.ts (the campaignId cross-check), src/engine/src/kinds/story-graph/state.ts, and src/engine/src/campaigns/adventure-builder.ts (the shared variable declarations).
Do not implement a bridge, a session stack, or a campaign-linking mechanism as part of answering this. The output is a recorded decision.
Stop if: the chosen answer requires a change to GameState's shape or to the save envelope's validation rules — that is a contract change and goes through design/ first.
A session belongs to exactly one campaign, and nothing decides what happens if content spans two
A proposal to let a scene offer a choice leading into another campaign's content — a side quest bridging Campaign A into Campaign B — has no answer today for what the session becomes at that moment, and the failure mode is silent rather than loud.
GameState.campaignIdis singular.resolveSaveEnvelopecross-checks that the envelope'scampaignIdmatches the embedded state's own and rejects a mismatch asinvalid_state. Achievements are recorded per(campaign, achievement). Downstream hosts denormalizecampaign_idper session and group player progress by it. Every one of those assumes a session sits in one campaign for its whole life.The sharp part is not that a bridge would fail these checks. It is that it might not fail at all. Story-graph variables are per-campaign namespaces, but the committed campaigns do not use distinct names — every campaign built through
adventure-builder.tsdeclares the sameroute,preparation,connectionsandpressure. Carrying Campaign A's variable state into Campaign B's nodes would not error; it would silently reinterpret A's values as B's, and the run would continue looking entirely normal while meaning something different. A crash would be better.So the semantics have to be decided before any bridging content exists, not discovered by building one. The candidate shapes each imply different state:
visitedCounts, and A's unfinished ending set?StoryGraphKindState, which does not have one.This issue is the decision, not the implementation.
Done when
design/90-decisions.mdfor what a session is when content crosses a campaign boundary — including the "not supported" outcome, if that is the answervisitedCounts, achievements and ending sets at the boundary is specifiedadventure-builder.tscampaigns is addressed rather than left as a latent silent-reinterpretation bugAgent instructions
Origin: evaluation of an external design handoff proposing cross-campaign side quests. That document explicitly left these semantics "deliberately not decided" — but its own first experiment would hit them immediately, which is why this is filed as a decision rather than folded into a spike.
Read first:
src/engine/src/core/kernel/types.ts(GameState),src/engine/src/core/persistence/envelope.ts(thecampaignIdcross-check),src/engine/src/kinds/story-graph/state.ts, andsrc/engine/src/campaigns/adventure-builder.ts(the shared variable declarations).Do not implement a bridge, a session stack, or a campaign-linking mechanism as part of answering this. The output is a recorded decision.
Stop if: the chosen answer requires a change to
GameState's shape or to the save envelope's validation rules — that is a contract change and goes throughdesign/first.