Skip to main content

Session Capture — Turning a Played Session Into a Fixture

Document status: Revision 2 — the capture operation (§2.1) and the fault-trigger bound (§5) are settled, and so is simulation's value review (§3.2). The capture operation ships (SessionLayer.captureSession, S139); the host's part (§8) stays post-MVP, gated on the hosting layer. Revision 1 specified what a fixture may hold.

Reading order: after 07-replay.md, which defines the fixture this produces and the oracle that consumes it. 05-observability.md §3.2 sets the privacy rule this extends.

Scope of this document

How a session played on a real deployment becomes a ReplayFixture — what is taken, what is refused, when it may be taken at all, and how long it lives.

It is mostly a privacy contract, not a mechanical one. The mechanics are already settled by 07; nothing new is needed to replay a captured session. What needs deciding is what may be captured from a person and kept.


1. Why This Is Separate From Replay​

07 §9 deferred this deliberately, and the reason holds: the two share a runner and almost nothing else.

Replay oracle (07)Session capture (here)
InputFixtures you wroteSessions someone played
RiskA false positive wastes an afternoonA mistake exposes a person's behaviour
Blocked byNothing — it is engineeringConsent, retention, jurisdiction
Needed whenThe second engine version existsA deployment has real players

It is gated on the hosting layer, which MVP §4 defers entirely. There is nothing to capture from a local text client that the developer did not type themselves. Specifying it now is worth doing because the shape of what may be taken constrains the session store and the observability boundary, and both are being built — but nothing here is MVP work.


2. What Is Captured​

A ReplayFixture (07 §2), and nothing else. Capture invents no format:

interface ReplayFixture { // 07 §2, unchanged
readonly name: string;
readonly config: NewGameConfig; // campaignId, seed, kindProfileData — nothing else
readonly campaignVersion: string; // the starting epoch (16 §5.6)
readonly capturedUnder: string; // engine version
readonly submissions: readonly Submission[];
}

That it needs no new type is the strongest argument for this design. A captured session is byte-for-byte the same artefact the test suite already runs, so a support case reproduces with no translation step and can be promoted into the corpus without conversion (§7).

A session that adopted new content (16) is captured with an AdoptionSubmission (07 §2) at each content entry's position. That is not a new format — the union is 07's — and an adoption carries no caller input to filter: its only value is a version the store chose from the host's channel, never one a client sent.

A session that is not replay-compatible is not captured. A StoredSessionRecord with replayCompatible: false has passed through a migrated load (04 §10.2), and its log no longer regenerates its state, so a fixture made from it would replay actions on content it does not name. Capture reads the flag from the session record and refuses, as branchSession does (04 §7.4). A session whose log carries a migration entry (16 §3.5) is the case readable from the log — the entry implies the flag, and a migration is not a step replay can perform. The flag also covers the case the log cannot show: a session migrated before it ever adopted, or in kind shape only, carries no entry.

A profiled session is captured with the input it was created from. The fixture's config.kindProfileData comes from the session record's initialization (04 §7.2), never from the profile as it is now. A record that predates that field is refused where branchSession refuses it (04 §7.4), because the creation input is lost.

2.1 The capture operation​

Capture is one engine operation, SessionLayer.captureSession(sessionId, name), and it returns the fixture and nothing else. It sits beside forget on the host-only layer (06-extensibility.md §4), not on SessionStore, for the reason in §4: the fixture carries the seed, and the seed never crosses the projection. A client that could call capture could read the seed, and with it every future draw in the session. So capture is not an MCP tool, and the client contract's one-to-one coverage (09) is unchanged.

StepWhat happensRefused with
1. HoldThe session is read on its own queue, after every queued command settles, exactly as branchSession reads its sourceunknown_session
2. AdmitThe same gates as branching: replayCompatible, no migration entry, an initialization where the kind has profile data, every epoch the log names still heldinvalid_state, unknown_campaign
3. ReplayThe whole log is replayed from {campaignId, seed, kindProfileData} at the starting epoch, every action and adoption in orderinvalid_state — an entry the kind now refuses
4. Buildconfig, campaignVersion, capturedUnder (the running ENGINE_VERSION), and one submission per log entry, with the caller's name—

Steps 1–3 are branchSession's, shared rather than copied. The two operations make the same claim — this log reproduces this session — and two copies of the refusals would drift, which is how this project's defects usually start. Branching replays a prefix and writes a new session from it. Capture replays all of it and writes nothing. Step 3 is not optional for capture: a fixture whose log the current engine refuses would come back from 07's runner as a rejected submission rather than the session, which is a false bug report.

Capture does not compare the replayed state with the stored one. A difference would be a determinism defect, which 04 §14's harness owns. Branching does not compare either. The fixture reproduces the log, and the log is the session's identity (04 §4).

The operation writes nothing. Storing a fixture, keeping it for the §6 window, and mapping it to an account are the host's (§8). The engine has no capture store, so it has no retention to enforce.

name is the caller's, and it must not identify anyone. It must not be derived from the session id, the profile, or the account: that would put §3.1's identity back in through the label. The host picks an opaque name, and promotion (§7) renames it after the bug.

The outcome baseline is not part of capture. 07's golden Outcome is produced at promotion, by replaying the fixture under capturedUnder. That is deterministic, so a baseline taken at capture would carry nothing extra. Producing the baseline at promotion also keeps capture's return value to a fixture that holds nothing but replay input.


3. What Is Refused​

Three rules, and the second is the one that makes the first honest.

3.1 No identity. profileId, sessionId, account identifiers, addresses, and host metadata are never written into a fixture. They live on the session store's own record (04 §7) and stay there. A fixture names a campaign and a seed, never a person.

3.2 Only declared params. ActionParams is Readonly<Record<string, string | number | boolean>> (04 §7) — arbitrary caller-supplied keys and values. Capturing them verbatim would put whatever a client chose to send into a stored artefact, which is exactly the hole 05-observability.md §8 closed for actionId.

So a fixture carries only the parameters the kind declares, and the kind enforces it at submission, not capture. Every kind rejects a param key its contract does not declare for that action, with its own unexpected_params (04 §3). A rejected action appends nothing to the log (04 §4), and capture reads the log, so every logged action's params are declared by construction. Capture copies them verbatim and filters nothing. For story-graph, which declares none (03 §8.2), this reduces to params are never captured at all.

Capture does not filter, because a filter breaks replay. Dropping a key the kind read changes the outcome, so the fixture would no longer reproduce the session it came from. A key the kind silently ignored could be dropped safely, but 04 §3 forbids ignoring one. The rule can therefore only hold where the parameters are judged, which is advance. A declared-parameter member on Kind would be a second copy of each kind's validator, the same objection 12 §7 makes to validateCommand.

The same holds for an actionId that resolves to no declared action. It is rejected (unknown_action), never logged, and so never captured, for the reason 05 §8 drops it from events: an unmatched id is caller-supplied text.

Values are each kind's own review, and every kind has done it. A declared string key could still carry free text if a kind accepted the value without checking it. Each kind therefore checks every string it logs against an id the campaign or the session minted:

KindHow a logged string value is bounded
story-graphDeclares no params (03 §8.2)
world-graphEvery id is checked against content or state before anything is applied (12 §6)
simulationplan.add checks targetId against its actionType's domain and choiceId against the pending response, and refuses a miss with unknown_target (10 §4). It used to defer that check to end_week, which let a caller's text sit in the plan and the log

The bound is issued, not valid. A target that is a real id but cannot be afforded, or whose requirements are not met, is still end_week's to resolve or reject. Being a real id is enough to keep caller text out of the log. Numbers and booleans carry no free text and are outside the review.

3.3 No timing. No wall-clock, no durations, no inter-action intervals. They are not replay inputs (04 §2 keeps clocks out of GameState entirely), and timing is behavioural data with no compensating value here.


4. The Seed Is the Sharp Edge​

A fixture must carry its seed — {seed, submissions} is the whole replay input, and without it nothing reproduces. But a seed plus a submission list reproduces one specific person's session exactly, which is precisely what makes it useful and precisely what makes it sensitive.

There is no clever way out of this, and the document does not pretend otherwise:

  • A fixture is personal data while it is a capture, and must be handled as such — access-controlled, retained under §6, deleted on request.
  • It stops being personal data only when it is de-identified by review at promotion (§7), which for a story-graph fixture is usually automatic: campaign id, seed, and a list of choice ids carry nothing about who made them once identity (§3.1) is absent.
  • A fixture is never attached to a public issue, a shared log stream, or anything an operator can read casually. That is a wiring rule, like the sink-selection rule in 05 §10.

A per-session overlay's version is as sensitive as the seed. A host that serves one player a private resolution (16 §4.2) makes that ResolutionId an identifier of the player's content, and a fixture naming it is personal data on the same terms. It also cannot be promoted (§7) until the overlay's content is itself reviewed and committed where the corpus can resolve it: a promoted fixture that names a resolution only one host's archive holds is permanently unrunnable everywhere else.


5. When a Capture May Be Taken​

Two triggers, both narrow:

TriggerWhoBound
On an engine faultAutomaticOnly on error-severity events (05 §7) — a settle guard trip, a rejected envelope. Not on ordinary rejected actions, which are normal play
On explicit reportThe player, or an operator acting on their reportOne session, named

Never as background collection. Capturing every session because it might be useful is what turns a debugging tool into surveillance, and the oracle does not need volume — 07 §4 wants a small corpus of meaningful fixtures, not a large one.

A fault capture takes the logged history, not the faulting submission. A submission that faulted appended nothing to the log, and capture reads the log (§3.2: acceptance is declaration). So the fixture reproduces the session up to the state the fault happened in, and stops there. Whoever investigates replays it and then makes the faulting call, which the host's fault event records. Two consequences, stated so neither is mistaken for a gap:

  • The faulting call's params are not in the fixture. They were never accepted. If a fixture carried them, a value the kind never judged would be stored — the leak §3.2 closes.
  • A rejected save blob (core.deserialize.rejected) is not reproduced. No submission produces that blob, so a capture of that session covers the play, not the bad blob. Handling the blob is the host's business under §8.

A fixture that ended on the faulting submission, with the fault as its expected outcome, would need a fault entry in 07's Outcome. runReplayFixture has no such entry, and a thrown guard would crash it rather than be recorded. That is deferred under §9.


6. Retention​

  • Bounded by default. A capture that has not been promoted (§7) within its retention window is deleted, and the window is short — measured in days, not indefinitely "until someone looks."
  • Deletion on request removes captures too. A capture is derived from a person's play, so a deletion request must reach it. Because captures carry no profileId (§3.1), the store must maintain the capture-to-account mapping outside the fixture — which is the hosting layer's job and is named as such in §8.
  • Deleting a capture never deletes a promoted fixture — see §7 for why that asymmetry is deliberate and what it costs.

7. Promotion Is a One-Way Door​

07 §4 says a confirmed bug becomes a committed golden fixture. Committing to git is permanent, and this is the moment that matters most:

Promotion is a reviewed, human step. A capture is never promoted automatically.

At promotion the reviewer confirms identity is absent (§3.1), including from the name the host gave it (§2.1). Replaying the fixture already proves no undeclared key and no unissued id survives (§3.2), since the kind rejects both. The reviewer renames the fixture to describe the bug rather than the session, and records the golden Outcome by replaying it under capturedUnder (07 §4). What is committed is then a test artefact, not a record of a person.

The asymmetry with §6 is deliberate and worth stating: retention deletion cannot reach a promoted fixture, because it is in the history of a public repository. That is exactly why promotion is gated on review rather than convenience — the moment before commit is the last one at which the decision is reversible.


8. What the Hosting Layer Owns​

Named so the boundary is not assumed:

  • Lawful basis and consent for capturing play, and how it is presented.
  • The capture-to-account mapping that makes §6 deletion possible without putting identity in the fixture.
  • Jurisdictional retention limits, where they are shorter than the default.
  • Access control on stored captures.

None of that is engine work. The engine's obligation is to make a fixture that is safe to keep — which is what §3 is for — and to make sure nothing in the replay path needs anything more.


9. Deferred​

  • A fixture that ends on its fault. §5 takes the history before a fault and leaves out the faulting call. To carry that call, with the fault as its expected result, 07's Outcome needs a fault entry, and runReplayFixture has to record a thrown guard instead of crashing. Reopen this when such an entry is proposed for 07.
  • Automatic de-identification checks at promotion. §7 is a human step. Replay now rejects an undeclared key and an unissued id (§3.2), so neither needs a lint. What remains is the fixture's name, the one field no kind judges. A check on it is worth adding once there is a corpus to run it against.
  • Capture from clients other than the hosted one. A self-hosting operator running their own deployment has their own obligations; this document does not attempt to specify them.