# Architecture This document describes the implemented `0.2.0` architecture. The compatibility target is the one-shot provider contract in `@deepseek-ai/dsh-subagent@0.1.0-rc.7`. ## Boundary and ownership The plugin adds a provider named `cassette` by default. It does not intercept the provider registry globally and does not replace `spawn` or another real provider. A call is recorded or replayed only when its descriptor selects the cassette provider. ```mermaid flowchart LR Caller["DSH caller"] --> Runtime["ctx.subagents"] Runtime --> Cassette["cassette provider"] Cassette -->|record| Upstream["real provider, e.g. spawn"] Upstream --> Cassette Cassette -->|append terminal outcome| File["cassette JSONL"] File -->|load and verify| Replay["offline matcher"] Replay -->|replay| Cassette Cassette --> Caller ``` The DSH one-shot ownership contract is preserved: 1. Before `start()` fulfills, the provider owns unpublished startup resources. 2. After fulfillment, the caller owns the returned `SubagentRun` and must dispose it. 3. `SubagentRun.result` carries the terminal result or a post-publication infrastructure rejection. The recorder wraps that lifecycle rather than inventing a second run model. ## Record path `installCassette()` performs these operations in record mode: 1. Validate provider names and find the registered upstream provider. 2. Resolve the output path and create a versioned header. 3. Open a `CassetteWriter` in `create`, `truncate`, or `append` mode. 4. Construct `RecordingSubagentProvider` and compile custom redaction patterns. 5. Register the cassette provider. For every admitted `start()` call, the recorder: 1. Snapshots and normalizes the request, validates the DSH `toolFilter` allow/deny contract, and builds either the metadata request view or the redacted full request view. 2. Resolves the live parent without committing it as the cassette root. 3. Snapshots the observable parent context and computes the canonical parent-context and request fingerprints. 4. Atomically commits the stable root mapping and per-group occurrence after all fallible preprocessing succeeds, then reserves a globally unique sequence number. 5. Calls the upstream provider with the original request. 6. Observes the returned run's result, abort signal, and disposal. 7. Snapshots and redacts the terminal result or error facts. 8. Serializes one interaction through the append queue. The original successful result snapshot is returned to the live caller. Redaction affects the persisted copy, not the record-mode caller's value. ### Failure points An upstream `start()` rejection is recorded as `start-error`, with `published: false`. A rejection of an already published run's `result` is recorded as `result-error`, with `published: true`. A successful `SubagentResult` is recorded as `result`. If a successful result cannot be represented as lossless plain JSON, snapshotting fails and the recorder persists that failure as a `result-error`. If persistence itself fails, the write failure becomes observable to the wrapped result or start path. ### Completion order and physical order `sequence` is reserved when the call is admitted. The line is appended only when that call reaches a terminal boundary. Concurrent calls can therefore appear in physical completion order while carrying a different sequence order. The writer serializes concurrent append operations and chains hashes in physical file order. An exclusive cross-process lock prevents a second library writer from forking the chain. Consumers must not infer completion order from `sequence`, or admission order from line position. ### Shutdown Record-mode disposal first unregisters the provider, preventing new starts. It then waits for every already admitted start to persist a terminal outcome before syncing and closing the file. Disposal can wait indefinitely if an admitted upstream run never settles and is never otherwise cancelled; the plugin does not seize ownership from the run's caller. ## Stable topology Volatile Agent and Session ids are not stored as match identities. `TopologyTracker` maps the first non-subagent parent to `root`. A local child exposed by an upstream run can be mapped to that run's `callKey`, producing paths such as: ```text root root/audit-5d663a77f142~1 root/audit-5d663a77f142~1/check-api-133f54e3e03a~1 ``` A call key contains: ```text /-~ ``` The readable label and 12-hex prefix of the combined parent-context/request identity are diagnostic only. Matching uses the full parent key, parent-context fingerprint, and request fingerprint. The recorder rejects: - more than one live top-level parent in one provider lifetime; - a subagent-origin parent that was not registered as a local child through this cassette provider; - attempts to map one child id to two call keys. This makes missing topology information explicit instead of silently falling back to volatile ids. Root mapping and occurrence reservation are transactional. Request normalization, parent-context fingerprinting, identity construction, and exact replay lookup complete before a new live root or occurrence is committed. A malformed request/context or an exact-match failure therefore cannot poison the tracker for a later valid call from the scenario's real root. ### Nested-call limit Nested mapping is useful while recording and for inspecting provenance. Replay intentionally returns no `localAgent` and does not execute the recorded local child. Consequently, this version is best used at the selected one-shot boundary of a scenario, rather than as a recursive simulator expected to recreate and consume an entire nested local tree. ## Request identity Before request matching, `normalizeParentContext()` snapshots the parent state used by DSH's official in-process child path: ```text Agent options stable session cwd, origin, delegationDepth, and agentPreset live composed preset and delegated sandbox/approval facts, when available completed-turn model-visible messages and the prefix's latest system/tools, only when inheritsParentContext is true ``` The completed prefix follows the official fork boundary: every event through the last `turn/end`, excluding the current unbalanced turn. The canonical session surface is folded and projected to messages. Volatile message ids are removed, while tool-call ids and their result correlations are renumbered by first structural occurrence. This allows equivalent history in a newly minted Session to match without weakening message content or ordering. The resulting SHA-256 value is `parentContextFingerprint`. It is deliberately conservative: changes in workspace, route, composition, or delegated policy fail matching; for an inheriting provider, changes in tools, system text, or completed history fail as well. A third-party provider may depend on additional Cordis or external state that the provider boundary cannot enumerate. Such dependencies remain outside the guarantee and must be controlled by the test environment. `normalizeRequest()` snapshots these stable request fields: ```text label, prompt, agentOptions, outputSchema, maxDepth, toolFilter, persona ``` Undefined optional fields are omitted. The abort signal, parent object, and descriptor are excluded. The snapshot must be lossless plain JSON. Canonical serialization sorts object keys and preserves array order; SHA-256 over that string becomes `requestFingerprint`. Request identity is strict. Changes in prompt whitespace, content block order, options, schema, depth, filter, persona, or label can produce a mismatch. There is no semantic fallback. ## Concurrent matching and duplicates Replay groups interactions by: ```text parentKey + NUL + parentContextFingerprint + NUL + requestFingerprint ``` Each group is ordered by `occurrence`. Matching removes the next interaction from the exact group, independently of the order in which other sibling groups are requested. This is why distinct concurrent sibling requests can reverse replay order safely. Identical requests under one parent context share a group. If that group has more than one recorded interaction and their canonical outcomes differ, its intended mapping cannot be inferred from request data alone. The default `duplicatePolicy: reject` fails while constructing the matcher. `duplicatePolicy: sequence` explicitly chooses occurrence order. It should be used only when the caller can guarantee the same duplicate start order. Stable, request-distinguishing labels or prompt fields are preferable to the sequence policy. ## Replay path `installCassette()` performs these operations in replay mode: 1. Resolve, read, parse, and validate the complete available cassette. 2. Validate exact schema and DSH target versions and the hash chain. 3. Reject ambiguous duplicate groups unless sequence policy is explicit. 4. Reject successful outcomes with redaction substitutions unless explicitly allowed. 5. Construct a matcher and register `ReplaySubagentProvider`. No upstream provider is looked up. The replay provider exposes the capabilities and `inheritsParentContext` facts captured in the header so the DSH service performs the same start-time capability checks. On `start()`, the matcher consumes one exact interaction. Replay optionally waits for recorded start latency. A recorded startup error rejects before returning a run. Otherwise it returns a synthetic run id, no local Agent, and a promise for the recorded successful outcome or post-publication error. Successful results are detached again before delivery. Recorded errors become `CassetteRecordedError`; the recorded error name is available as `recordedName` and its string code is preserved. ### Timing and cancellation With `timing: instant`, both delay components are zero. With `timing: recorded`, replay uses: ```text start delay = startLatencyMs / speed result delay = (durationMs - startLatencyMs) / speed ``` The replay provider accepts only `instant` or `recorded` timing and a finite `speed >= 0.001`. It validates each scaled delay as non-negative and finite before handing it to the timer loop, so extreme recorded values cannot silently become an infinite wait. Long finite waits are scheduled in bounded timer chunks, each of which remains abortable. An already-aborted signal, or an abort during start delay, rejects publication. An abort after publication, or explicit run disposal, resolves the result as an aborted `SubagentResult`. These are live replay controls; replay does not force the original recording's abort moment to recur. ## Consumption assertion Every matched interaction is marked consumed. `CassetteHandle.assertConsumed()` checks immediately, and replay-mode disposal performs the same check by default after unregistering the provider. This catches a scenario that made fewer exact calls than the cassette contains. Set `assertConsumed: false` only when partial replay is intentional. Extra live calls always fail at match time regardless of this option. ## Structured mismatch diagnostics `loadCassette()` validates the persisted boundary before the matcher index is built. Cassette-owned header, target, provider/capability, interaction, timing, request-view, outcome, and recorded-error wrappers have closed field sets, and each interaction's storage arm must agree with the header's `requestStorage` policy. DSH `SubagentResult` data and unknown typed content blocks remain deliberate extension points. Replay, inspect, diff, and append therefore share the same fail-closed boundary without blocking compatible DSH result extensions. `createHeader()` validates the document it constructs. The writer revalidates a requested header before creating or locking a target and applies the same interaction validator before committing each JSONL record, including result content-block checks. Invalid runtime request filters fail during request normalization before the upstream provider starts; invalid direct `CassetteWriter` input is rejected without writing a header or interaction. Content-block discovery, validation, and redaction traverse nested `tool-result.content` with explicit worklists. Validation performs linear work per visited block and avoids building depth-sized paths for every node; the regression suite records and reloads a 20,000-level nested result. Schema-aware redaction protects structural `type` discriminators and image `mediaType` values while continuing to redact payload strings. A pattern that would alter either protected field fails recording with `INVALID_CONFIG`, avoiding both silent redaction gaps and structurally unloadable output. The matcher retains an immutable, admission-sequence-ordered interaction index beside its consumable exact-match queues. `diagnose(request)` computes the same parent key, parent-context fingerprint, and request fingerprint as `match()`, but only peeks at the topology and queue. It neither consumes an interaction nor reserves a previously unseen top-level parent. The result is a discriminated union with `status: match` or `status: mismatch`. Mismatches classify exact-group exhaustion, parent-context drift, request drift, combined drift, and an unavailable parent topology. `match()` uses the same analysis to populate `CassetteMismatchError.diagnostic`, so human errors and programmatic diagnostics cannot diverge from matching rules. Candidate selection remains explanatory. It never changes the strict replay key and never supplies a fallback interaction. Candidates are ordered by recorded admission `sequence`, include their consumption state, and expose only request metadata, topology keys, fingerprints, and occurrence information. Metadata candidates are rebuilt through an explicit allowlist rather than exposing stored objects directly, preventing forged or future extra fields from crossing the diagnostic boundary. Stored prompts, results, and recorded error bodies are not projected into diagnostics. ## Cassette diff `diffCassettes(expected, actual)` compares two already verified cassette documents using this exact identity: ```text parentKey + NUL + parentContextFingerprint + NUL + requestFingerprint + NUL + occurrence ``` Physical line order, `sequence`, cassette id, creation time, record hashes, timestamps, and timing do not participate in alignment. For matched identities, canonical outcome fingerprints and the `published`/`local` boundary facts are compared. Timing deltas are returned as observational data but do not affect equivalence. The comparison also reports provider and persistence-policy drift. Differing `inheritsParentContext` values make the documents not safely comparable because their parent fingerprints have different semantics. Ambiguous duplicate outcome groups also fail closed instead of treating occurrence order as an inferred identity. The CLI maps an equivalent comparison to exit code `0`, a material difference or non-comparable input to `2`, and usage/read/format errors to `1`. Human and JSON reports contain metadata and fingerprints only, never stored request or outcome bodies. Inspect and diff normalize stop reasons into the public categories `completed`, `aborted`, `error`, `max-tokens`, `refusal`, and `other`; unknown raw strings never cross that reporting boundary. Canonical outcome fingerprinting still uses the exact stored outcome, so two different unknown strings remain detectable as an outcome change without being disclosed. Human output renders Unicode control, format, line-separator, and paragraph-separator characters as visible `\u{...}` escapes; JSON output encodes them only inside string literals and remains valid JSON. ## Component map | Module | Responsibility | |---|---| | `src/index.ts` | Cordis schema, plugin entry point, installation, registration, and teardown. | | `src/provider.ts` | Recording and offline replay provider lifecycle. | | `src/topology.ts` | Stable parent paths, occurrence reservation, strict matching, consumption checks. | | `src/canonical.ts` | Lossless parent/request/result snapshots, volatile-id normalization, canonical JSON, metadata, and SHA-256 fingerprints. | | `src/redact.ts` | Built-in and configurable persistence redaction. | | `src/format.ts` | Versioned JSONL parsing, validation, hash chain, append writer, summaries. | | `src/diff.ts` | Strict metadata-safe cassette comparison and timing deltas. | | `src/cli.ts` | Metadata-only `verify`, `inspect`, and `diff` commands. | | `src/errors.ts` | Stable cassette error classes and codes. | ## Deliberate extension points Public TypeScript declarations track the validated runtime shapes: recursive JSON objects are exposed as `JsonObject`, normalized request prompts use DSH `ContentBlock[]`, normalized `agentOptions`/`outputSchema` require objects, results use DSH `SubagentResult`, and tool filters use the allow/deny-only `NormalizedToolFilter`. The DSH-owned `ContentBlock` and `SubagentResult` surfaces retain their documented extension behavior. - The public `installCassette()` API supports programmatic Cordis composition. - `dsh-subagent-cassette/format` exports the parser, writer, and summary APIs for trusted tooling. - Provider and file names are configurable, so independent cassettes can coexist in one deployment if they use distinct provider registrations. Adding continuable behavior, authenticated manifests, or a trailer commit would change ownership or format semantics and should be designed as an explicit versioned feature rather than inferred from the current API.