# Linked session recordings ## Files and ownership `recording.SessionRecorder` owns one recording per `run.main()` invocation. It has no game or model client. It records and invokes an action callback supplied by the runner. Policy execution remains in the runner. ```text capture/ decisions.jsonl compatibility feed sessions.jsonl compatibility session summaries sessions// events.jsonl authoritative, ordered journal session.json finalized session summary observations/000001.json full parsed observation observations/000002.json ... ``` The session ID combines a timestamp with a random UUID. It is generated inside each invocation, not once per Python process. Session directories and observation files are created exclusively. Later sessions cannot overwrite earlier captures. The default session artifacts are ignored by version control. All successful state reads are captured, including preflight, menus, overlays, selection screens, rewards, and terminal screens. Identical observations remain separate reads. The old `live__combat.json` files are no longer written. Observation files contain the unmodified parsed state dictionary. They are not copies of HTTP headers or the original wire encoding. Each observation event contains the relative path and SHA-256 hash of the stored bytes. Paths are relative to the configured capture root. ## Journal schema, version 1 Each journal row includes `schema_version`, `session`, `sequence`, `recorded_at` (UTC), and the legacy local-time `ts` display field. Sequence orders events within one session. It is not an ordering across processes. | Event | Purpose and links | | --- | --- | | `session_start` | Establish the session before preflight or model initialization. | | `observation` | Full state reference: `observation_id`, `path`, `sha256`, step, phase, and state type. | | `proposal` | Policy/system decision, linked by `proposal_id` and `observation_id`. Includes action, parameters, reason, source, and available model evidence. | | `proposal_status` | Disposition of a proposal: `wait`, `suppressed`, `dry_run`, `execute`, or `session_stopped`. | | `action_attempt` | Intent to submit one request, linked to its proposal and input observation. Has a distinct `attempt_id`. | | `action_result` | Response to an attempt: `accepted`, `rejected`, or `unknown`. Response/message or transport error is retained. | | `session_end` | Final exit code, reason, outcome-file candidates, and any attempt still lacking a subsequent observation. | Existing diagnostic events, such as `model_error`, `action_error`, and identity changes, also appear in the journal. `observation_error` records a failed state read without inventing an observation. Its optional observation reference is the last successful read, not a payload for the failed request. ### Join direction ```text observation.observation_id <- proposal.observation_id <- action_attempt.proposal_id <- action_result.attempt_id <- subsequent observation.after_attempt_id ``` The first successful read after an attempt carries `after_attempt_id`. Later reads do not reuse that field until another attempt occurs. A terminal dismissal may have no subsequent read. The session summary explicitly preserves that unresolved link in `awaiting_observation_after_attempt`. The subsequent observation is temporal evidence, not proof of causation or completion. A transition overlay can be that first read while policy memory continues waiting. The journal does not invent a semantic success label. Rejected requests are attempts too. Waits, suppressed duplicates, and dry-run previews have proposals and dispositions, but no attempt or action result. Dry-run proposals do not update execution-based duplicate suppression. Preflight dismissal and stop-on-run-end dismissal use the same recording path as policy actions. Their observations, proposals, attempts, and results are linked. ## Model evidence Successful model calls retain the request state, questions, and parsed answers with the proposal. Failed model calls retain the request with `model_error`. This is not a raw HTTP transcript or a ledger of the model client's internal retries. `answer_gate_scope: default_helper_not_policy_gate` labels the existing per-answer `gated` field. That value does not establish the actual gate used by a particular policy handler. Actual policy-gate metadata remains separate work. No correctness label is inferred from confidence, action acceptance, or a run outcome. Reported run IDs and card provenance remain subject to the limits in [run-state documentation](RUN_STATE.md). The recorder does not strengthen save-derived identity into an atomic observation identity. ## Durability and failure behavior Observation files are flushed before their references are used. The journal's action intent is flushed before the runner calls the game transport. A recording failure before that point prevents the POST. A transport failure has an `unknown` result and is not retried automatically. If recording fails after a POST, its effect can remain unknown. A missing result must never be interpreted as rejection or permission to retry. Finalization runs synchronously when the session scope exits. It covers normal returns, startup failures, handled runtime failures, Python exceptions, and keyboard interruption. There is no process-global session ID or `atexit` callback. SIGTERM, SIGKILL, power failure, and storage failure can still leave an incomplete journal or no summary. An intent can exist even if the process died before the POST began. A file flush is not a transactional guarantee across the journal, observation files, and game service. Readers must treat incomplete records and truncated final lines as incomplete evidence, not as successful execution. ## Compatibility and dataset boundaries The root `decisions.jsonl` retains `event: decide` rows for unsuppressed actions and previews. These carry the same IDs as their canonical proposals. Wait and suppressed proposals appear only in the session journal. Do not count both a `proposal` and its compatibility `decide` row as separate policy decisions. The root session feed retains the outcome-file list and points to its journal through `recording_path`. The historical outcome association remains a filename set difference; it is not proof that every new game history file belongs to this bot. An attribution failure is recorded as `outcome_error` without replacing the session's exit status. Compatibility feeds are not transactional or coordinated across writers. Use per-session journals for reliable joins when multiple processes share a capture root. Full observations and file flushes add storage and I/O cost intentionally. Existing captures and `dataset/` are unchanged. The current dataset migrator still reads historical inputs and flat captures; it does not import these new journals automatically. A journal importer and semantic reconciliation labels can be added separately without rebuilding data during development. ## Validation - The backend-only recording bookmark passed 294 assertions: 68 facts, 110 policy, and 116 runner assertions. With the separate upgrade-selection fix applied, the integrated workspace passed 296 assertions (118 runner assertions). - Runner flows verify hashes, unchanged observation contents, all join keys, preflight/terminal actions, rejection, unknown transport outcomes, waits, duplicate suppression, repeated invocations, and keyboard interruption. - Twenty-five distinct temporary whole-process scenarios passed with real HTTP clients and local fixture servers. The server checked that each action intent existed on disk before it received the action POST. - Injected journal failures before and after POST verify conservative stopping. - All prior semantic code bookmarks passed their three Python test scripts. - Dataset integrity, shell syntax, and offline snapshot replay passed. No live game actions, paid model calls, new dependencies, or permanent standalone unit-test files were needed.