Replay — The Regression Oracle
Document status: Revision 1 — new contract, post-MVP
Reading order: after 04-core.md §14, which this extends. The
determinism harness answers is the engine deterministic; this answers did a change alter
a game that already exists.
Scope of this document
Replaying committed game data across engine versions, and comparing what came out. It defines the
Outcome— what "the same game" means when the bytes are allowed to differ — the corpus, the runner, and how an intended change is distinguished from a regression.It does not cover capturing sessions from a running deployment. That is the second half of the idea and is deferred to §9, because it shares the runner and almost nothing else.
1. Why the Existing Harness Is Not This
The determinism harness (04-core.md §14) already replays
{config, actionLog} and asserts a byte-identical serialize(). It is the right tool for
the question it answers:
| Question | Tool | Compares |
|---|---|---|
| Is the engine deterministic? | Determinism harness (04 §14) | This build against itself |
| Did this change alter an existing game? | This document | This build against a previous build |
The distinction matters because the harness is blind by design to the thing a regression oracle must catch. A change that alters every game identically — a new consequence ordering, a corrected requirement evaluation — is perfectly deterministic. The harness runs green. Every existing game now plays differently, and nothing said so.
Golden files narrow that gap and do not close it: a golden serialize() blob fails on any
change to the serialization, which is frequently intended, and so it cannot distinguish
"the format moved" from "the game changed."
2. Fixtures Are Inputs, Not State
The load-bearing property, and the reason this is tractable at all:
interface ReplayFixture {
readonly name: string;
readonly config: NewGameConfig; // campaignId, seed (04 §5)
readonly campaignVersion: string; // pinned here, not in NewGameConfig — below
readonly capturedUnder: string; // the engine version that recorded the outcome
readonly submissions: readonly Submission[]; // every attempt, accepted or not — §2.1
}
interface Submission {
readonly actionId: string;
readonly params?: Readonly<Record<string, string | number | boolean>>;
}
Every value is an id or a primitive, and ids are stable once published — a rename is a
migration (04-core.md §17). Nothing here is engine internals.
Why this is not
PlaythroughFixture(04 §14). Two fields that document needs and the determinism harness does not.
NewGameConfigcarriescampaignId,seedandaudience— nocampaignVersion. The determinism harness does not need one: it runs against whatever the registry currently holds, and compares a build against itself in the same process. A cross-version oracle does need one, because "the same game" is only meaningful against a pinned content version, and becauseunrunnable(§6) has to be able to say which version went missing.
capturedUnderexists for the same reason: a divergence report is not actionable without naming both engine versions that disagree. Neither field belongs onNewGameConfig, which is a runtime input — pinning them on the fixture keeps the core contract unchanged.
2.1 Submissions, Not the Action Log
A fixture records every submitted action, including rejected ones. The action log does not.
04 §4 is explicit that a rejected action leaves state unchanged and appends nothing to
actionLog — seq is the log's length, so the next attempt reuses it. That rule is correct
for replay determinism and it makes actionLog unusable as a submission history: the
rejections are simply not in it.
Reusing it here would have been self-defeating. §3 promises one Decision per submitted
action and §6 continues past a rejection specifically to see whether a later action
recovered — neither of which is possible from data that excludes rejections. So the fixture
carries its own submissions list, and Decision[] is exactly parallel to it.
The relationship to 04 §14 is then clean: filtering submissions to the accepted ones
reconstructs an actionLog, so a ReplayFixture can always produce a PlaythroughFixture,
but not the reverse.
Contrast the save path, which carries state and therefore carries the whole versioning
problem — saveFormatVersion, serializationVersion, replayCompatible (04 §10.2). A
migrated save is explicitly not replay-compatible, because its action log can no longer
be guaranteed to regenerate it.
So cross-version replay largely sidesteps migration. A fixture from an older engine is still a valid input to a newer one, because the newer engine still knows what a campaign id and an action id are. This is the opposite of the save story and it is worth stating plainly, since the two are easily conflated: replay old inputs, never old state.
The exception is real but narrow: a fixture whose campaign no longer exists, or whose
campaignVersionhas been withdrawn, cannot run at all. §6 treats that as a distinct result rather than a failure.
3. What "The Same Outcome" Means
Bytes cannot be the comparison, since serialization is allowed to change. So the oracle
compares an Outcome — a deliberately small projection built only from vocabulary the
platform has already promised to keep stable.
interface Outcome {
readonly finalStatus: GameStatus; // active | ended | abandoned (04 §2)
readonly acceptedActions: number; // how far the log got before diverging
readonly decisions: readonly Decision[]; // one per SUBMISSION, in order — §2.1
readonly achievements: readonly string[]; // unlocked ids, sorted — §3.2
readonly terminal?: unknown; // the kind's terminal identity — §3.3
}
interface Decision {
readonly index: number; // 0-based position in submissions — §3.1
readonly seq: number | null; // the accepted log position, null if rejected
readonly actionId: string;
readonly accepted: boolean;
readonly reason?: ReasonCode; // set iff rejected (04 §12)
}
Every field is stable across versions by an existing decree, not by hope:
GameStatusis a closed three-value union (04 §2).ReasonCodes are additive and never renamed (04 §12) — saves and replay logs reference them, so a rename already breaks old data and is already forbidden. That makes them the ideal cross-version vocabulary: the platform guarantees their meaning survives exactly as long as this oracle needs it to.- Achievement ids are stable published ids (04 §17), read as §3.2 describes.
acceptedActionsis a count of log entries.
3.1 index, Because seq Is Not Unique
A rejected action does not advance seq (04 §4), so two rejected submissions at the same
position share one. seq therefore cannot identify a submission, and a divergence reported
at a seq would be ambiguous exactly where rejections cluster — which is where this oracle
is most useful.
index is the 0-based position in submissions and is unique by construction. seq is
retained as number | null because it is still the useful cross-reference into the action
log and into the observability stream (05 §5), and null states outright that a rejected
submission has no log position rather than leaving a repeated number to be misread.
3.2 Where Achievements Come From
unlockedAchievements lives in kindState, which is unknown to the core (04 §2), so a
pure-engine runner cannot read it. The profile store can — it is keyed
campaignId + achievementId (04 §7.1) and is kind-agnostic — but only if there is a profile
at all, and an anonymous session has none.
So the runner is a session-layer runner, not a pure-engine one. It composes
createSessionLayer (06 §4) with an in-memory ProfileStore and a fixed profileId, and
reads the unlocked set from the profile after the last submission. That is a deliberate
choice with a cost — the oracle exercises one layer more than the determinism harness does —
and the alternative was dropping achievements from Outcome entirely, which would blind it
to "this arc no longer unlocks its achievement", a regression worth catching.
3.3 terminal — Terminal Identity, and Only That
finalStatus records that a game ended. It cannot record which ending, because
endingId lives in kindState. Without it, a change that routes the last action to a
different ending — same decisions, same counts, same status — compares equal, and the
oracle reports match on exactly the kind of regression it exists to catch.
So Kind gains one member, mirroring project (04 §9):
interface Kind<KState> {
// …existing members (04 §3)…
/** A minimal, cross-version-stable terminal identity. Ids only — never values. */
outcome(state: KState): unknown;
}
The constraint is what keeps it from becoming a false-positive generator. A kind returns
published ids — story-graph returns { endingId } (03 §8.5) — and never variable values, counts,
or anything a content rebalance legitimately changes. §3.4 stays the rule; outcome is the
narrow exception for terminal identity, not a door back to state comparison.
This was deferred in the first draft and the deferral was wrong: it traded a real, silent miss for the avoidance of a hypothetical one.
Nothing here is kind-specific, which is the point — the oracle works for story-graph
and simulation alike with no new Kind member and no per-kind maintenance.
What
decisionscatches, and why it is the valuable field. It records, for every action a player actually submitted, whether the engine took it and why not if it did not. That is the direct expression of does this game still play the same way: a choice that used to be available and is now gated flips oneacceptedfrom true to false, and names theReasonCodethat did it. A final-state comparison would report only that the game ended somewhere different, several turns later, with no indication of where it diverged.
3.4 What Is Deliberately Not in Outcome
- Variable values. Kind-specific, and they change for legitimate reasons — a rebalance
is not a regression. Including them would make the oracle cry wolf on every content edit.
This is the rule
terminal(§3.3) is the single narrow exception to. - The event stream (
05-observability.md§5). It is golden-fileable and it is a fine debugging aid, but event names are explicitly additive and retirable (05 §3.1), so the stream is designed to change. Comparing it would report intended additions as regressions. serialize()bytes. The whole reason this document exists (§1).
4. The Corpus
A committed set of fixtures, each with its recorded Outcome:
fixtures/replay/
bureaucracy-happy-path.fixture.json
bureaucracy-happy-path.outcome.json ← the oracle, reviewed like code
bureaucracy-gate-blocked.fixture.json
bureaucracy-gate-blocked.outcome.json
The outcome file is the artifact under review. A change to it is a statement that the game changed, and it should read that way in a diff.
Where fixtures come from, in priority order:
- Confirmed bugs. 05 §11 already establishes that a bug report is a
PlaythroughFixture. Every fixed bug should leave one behind — the case that broke is the case most worth watching. - Definition-of-Done paths. Each MVP §5 playable box is a fixture: the arc completed, the loop traversed, the gate reached, the achievement unlocked.
- Deliberate edge cases. A rejected action, an unknown action, a start that settles
straight to an ending (04 §11, Tier 2
no_reachable_choice).
A fixture records capturedUnder and campaignVersion (§2). Not to migrate anything —
§2 explains why that is unnecessary — but so a divergence report can name which engine
versions disagree, and so unrunnable can distinguish a withdrawn campaign from a missing
version of one.
5. Prerequisite: A Controllable IdSource
Cross-version replay requires createGame to be reproducible, and until
06-extensibility.md §5.1 named the IdSource port it was not:
gameId came from nowhere specified and seed was "store-generated".
The runner supplies a counting IdSource, so gameId is fixed and any seed the
fixture omits is derived rather than random. Without it the oracle would have to exclude
game identity from comparison and could not replay creation at all — it would have to start
one action in, which is exactly where several interesting divergences live.
6. The Runner and Its Verdicts
type ReplayVerdict =
| { kind: "match" }
| { kind: "diverged"; at: number; capturedUnder: string;
expected: Outcome; actual: Outcome }
| { kind: "unrunnable"; reason: "campaign_withdrawn" | "campaign_version_missing" };
The runner resolves the fixture's campaignVersion in the registry, creates a session layer
(06 §4) with a counting IdSource and an in-memory ProfileStore (§3.2), creates a game
from config, submits each Submission in order, builds an Outcome, and compares.
at is the index of the first differing Decision, not a seq — §3.1 explains why
seq cannot serve. capturedUnder comes from the fixture, so a divergence report names both
engine versions that disagree rather than only the one running.
Three verdicts, deliberately, rather than pass/fail:
match— the game plays as recorded.diverged— it does not, andatis theindexof the first differingDecision(§3.1), so the report points at the submission that changed rather than at the end of the game.unrunnable— the fixture's content is gone (§2). Not a failure, because a withdrawn campaign is a legitimate content decision, and reporting it as a regression would train the team to ignore the suite. It is reported and counted separately.
A rejected action does not stop the replay. The runner submits every logged action and
records each Decision, because the interesting signal is often that a later action
recovered or did not. Stopping at the first rejection would discard it.
7. Intended Change Versus Regression
The oracle cannot tell them apart, and should not try. It reports a divergence; a human decides which it is.
The workflow makes the decision explicit and reviewable:
- The suite reports a divergence with its
atand both outcomes. - Either the change was unintended — fix the engine — or it was intended, and the
committed
.outcome.jsonis regenerated in the same commit as the change that caused it. - That regeneration is never automatic. A command that silently rewrites every outcome file turns the oracle into a rubber stamp, which is the failure mode this whole document exists to avoid.
The diff is the deliverable. An outcome file changing from
accepted: truetoaccepted: false, reason: "requirement_unmet"atseq: 4is a reviewable sentence: the fourth choice in this arc is now gated. That is the artifact worth having, and it is whyOutcomeis small — a large one produces diffs nobody reads.
8. Where This Runs
Not on every commit. The corpus grows without bound and most changes cannot affect it.
- On changes to
src/engine/src/core/orkinds/— the code that can alter a game (Engine Package). - On every release tag, against the previous tag's corpus, which is the comparison the oracle is actually for.
- Never as a merge gate on documentation-only changes, which is most of this repository's traffic today.
9. Deferred
- Session capture from a deployment — the second half of the original idea, now
specified separately in
08-session-capture.md. It produces exactly theReplayFixtureabove and needs no new format; what it needed was a privacy contract, which is why it is its own document and is gated on the hosting layer rather than on anything here. - Widening
Kind.outcomebeyond terminal identity — the member exists (§3.3) and is deliberately confined to published ids. Letting a kind contribute counts or values would make the oracle sensitive to content rebalancing, which §3.4 exists to prevent. Revisit only against a divergence that terminal identity provably missed. - Bisecting a divergence across versions — given a
divergedverdict, finding the commit that caused it. Ordinarygit bisectover the replay command covers this without new engine work. - Cross-kind replay — meaningless: a fixture names a campaign, and a campaign names exactly one kind (02 §1).