From 320de5466afa2de305064f25e9d7f2ce4c3fd245 Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Mon, 13 Jul 2026 13:44:01 +0800 Subject: [PATCH 01/12] feat(session-query): add relationship tracing (round 1) --- docs/architecture.md | 2 +- docs/capability-seams.md | 4 +- docs/config-catalog.md | 2 +- docs/cordis-catalog/services.md | 6 +- docs/core-data-structures/core.md | 2 +- docs/core-data-structures/session-query.md | 54 ++- docs/rfc/INDEX.md | 1 + .../2026-07-10-session-query-service.md | 13 +- .../2026-07-13-session-query-tracing.md | 34 ++ ...026-07-10-sqlite-session-query-provider.md | 6 +- packages/README.md | 2 +- .../cordis/tool-cordis/src/api-catalog.ts | 20 +- packages/session-query/README.md | 6 +- .../session-query/session-query/README.md | 12 +- .../session-query/session-query/package.json | 2 +- .../session-query/session-query/src/config.ts | 6 +- .../session-query/session-query/src/index.ts | 54 +-- .../session-query/src/tracing.ts | 277 +++++++++++++ .../session-query/session-query/src/types.ts | 58 ++- .../session-query/tests/tracing.spec.ts | 376 ++++++++++++++++++ scripts/gen-doc-graphs.ts | 4 +- scripts/type-equiv.manifest.json | 4 + 22 files changed, 885 insertions(+), 60 deletions(-) create mode 100644 docs/rfc/implemented/feature/2026-07-13-session-query-tracing.md create mode 100644 packages/session-query/session-query/src/tracing.ts create mode 100644 packages/session-query/session-query/tests/tracing.spec.ts diff --git a/docs/architecture.md b/docs/architecture.md index 0b8cb6e304..79d59981e8 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -35,7 +35,7 @@ Composition is preferred over inheritance. `packages/core/` is a repository grou | `ctx.subagents` | [`subagent/`](../packages/subagent/README.md) | named delegation providers | | `ctx.workflows` | [`workflow/`](../packages/workflow/README.md) | script-driven multi-agent orchestration | | `ctx.sessionPersistence` | [`session-persistence/`](../packages/session-persistence/README.md) | durable storage for session logs | -| `ctx.sessionQuery` | [`session-query/`](../packages/session-query/README.md) | live-preferred logical-corpus and exact-event reads | +| `ctx.sessionQuery` | [`session-query/`](../packages/session-query/README.md) | live-preferred exact reads and relationship traces | ## Event diff --git a/docs/capability-seams.md b/docs/capability-seams.md index dd7e3f2379..15887725e1 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -25,7 +25,7 @@ flowchart LR pkg_session_persistence_jsonl["session-persistence-jsonl"] pkg_session_persistence_sqlite["session-persistence-sqlite"] pkg_acp["acp"] - svc_sessionQuery["ctx.sessionQuery
Exact session-history reads"] + svc_sessionQuery["ctx.sessionQuery
Exact session-history reads and traces"] pkg_system_prompt["system-prompt"] svc_systemPrompt["ctx.systemPrompt
System prompt assembly registry"] pkg_tools["tools"] @@ -181,7 +181,7 @@ flowchart LR | `ctx.llm` | `seam` | [`llm`](../packages/llm/llm) | [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai), [`llm-replay`](../packages/support/llm-replay) | [`agent-loop`](../packages/core/agent-loop), [`compact-basic`](../packages/compact/compact-basic) | - | Adapters register provider implementations; the loop and compaction call the provider-neutral stream service. | | `ctx.sessions` | `core` | [`session`](../packages/core/session) | - | [`agent-loop`](../packages/core/agent-loop), [`agent`](../packages/core/agent), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-query`](../packages/session-query/session-query), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`invariants`](../packages/support/invariants) | - | Owns append-only Session instances and emits the durable session event feed. | | `ctx.sessionPersistence` | `seam` | [`session-persistence`](../packages/session-persistence/session-persistence) | [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`session-persistence-sqlite`](../packages/session-persistence/session-persistence-sqlite) | [`agent-loop`](../packages/core/agent-loop), [`acp`](../packages/ui/acp), [`session-query`](../packages/session-query/session-query) | - | Backends persist the same SessionEvent vocabulary; apps choose a backend at composition time. | -| `ctx.sessionQuery` | `seam` | [`session-query`](../packages/session-query/session-query) | - | - | - | Resolves live and optional persisted logs into one logical corpus for exact reads. | +| `ctx.sessionQuery` | `seam` | [`session-query`](../packages/session-query/session-query) | - | - | - | Resolves live and optional persisted logs into one logical corpus for exact reads and relationship traces. | | `ctx.systemPrompt` | `core` | [`system-prompt`](../packages/core/system-prompt) | - | [`agent-loop`](../packages/core/agent-loop), [`tools`](../packages/core/tools), [`tool-fs`](../packages/fs/tool-fs), [`tool-web`](../packages/web/tool-web) | - | Collects prompt sections and model-facing tool schemas for each step. | | `ctx.tools` | `core` | [`tools`](../packages/core/tools) | - | [`agent-loop`](../packages/core/agent-loop), [`tool-ask-user`](../packages/ui/tool-ask-user), [`tool-bash`](../packages/bash/tool-bash), [`tool-cordis`](../packages/cordis/tool-cordis), [`tool-fs`](../packages/fs/tool-fs), [`tool-skill`](../packages/skill/tool-skill), [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-todo`](../packages/todo/tool-todo), [`tool-web`](../packages/web/tool-web), [`acp`](../packages/ui/acp) | - | Registers tool definitions, exposes schemas to the prompt, and routes calls through tools/pre-execute and tools/post-execute. | | `ctx.userInteraction` | `seam` | [`user-interaction`](../packages/ui/user-interaction) | [`stdio-agent`](../packages/ui/stdio-agent), [`acp`](../packages/ui/acp) | [`tool-ask-user`](../packages/ui/tool-ask-user), [`stdio-agent`](../packages/ui/stdio-agent), [`acp`](../packages/ui/acp) | - | UI front doors provide the active human-answer provider; tool-ask-user pauses a tool call on the provider-neutral ask() promise. | diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 6193ff8449..608046d053 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -578,7 +578,7 @@ Source: [`packages/session-persistence/session-persistence-sqlite/src/index.ts:5 Requires: `sessions` ```ts config-catalog -/** Configuration for exact session-query reads. */ +/** Configuration for exact session-query reads and traces. */ export interface Config { /** Maximum accepted raw read context on either side. Defaults to 50. */ readWindowMax?: number diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index d8e4cbb107..666dbb0b33 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -203,15 +203,17 @@ Source: [`packages/session-persistence/session-persistence/src/index.ts:102`](.. ## `ctx.sessionQuery` — `SessionQueryService` -Live-preferred logical-corpus and exact-event read service. +Live-preferred logical-corpus exact-read and relationship-tracing service. ```ts cordis-catalog listSessions(): Promise async listEvents(sessionId: SessionId): Promise +async traceSession(sessionId: SessionId): Promise +async traceEvent(request: SessionEventTraceRequest): Promise async readEvent(request: SessionEventReadRequest): Promise ``` -Source: [`packages/session-query/session-query/src/index.ts:35`](../../packages/session-query/session-query/src/index.ts) +Source: [`packages/session-query/session-query/src/index.ts:38`](../../packages/session-query/session-query/src/index.ts) ## `ctx.sessions` — `SessionStore` diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index 6a2cbcfa60..b2d07b229c 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -18,7 +18,7 @@ Everything else is documented on a **sub-page**, not here. The rule that draws t | [llm-streaming.md](llm-streaming.md) | the `StreamChunk` wire protocol + adapter contract, `BlockAssembler`, the `LlmAdapter` seam | | [session.md](session.md) | the full `SessionEventMap` variant catalog, `TurnTrigger`/`TurnEndReason`, `deriveMessages()`, the turn-enclosure invariant | | [persistence.md](persistence.md) | the durability seam: `SessionPersistence`, JSONL + SQLite backends, `session/flush`, crash recovery, `SessionHeader` | -| [session-query.md](session-query.md) | logical session/event records and bounded exact-event reads | +| [session-query.md](session-query.md) | logical records, bounded exact-event reads, and relationship traces | | [tools.md](tools.md) | `ToolDefinition` full fields, the schema DSL, `ToolExecution`/`ToolResult`, tool-presentation UI types, the `tools/pre-execute`/`tools/post-execute` pipeline | | [user-interaction.md](user-interaction.md) | the UI-backed human question/answer seam: `AskUserQuestionRequest`, answer/options vocabulary, provider API, error taxonomy | | [approval.md](approval.md) | the one-shot user-approval seam: `ApprovalRequest`, `ApprovalOutcome`, per-session policy, audit and answerer contracts | diff --git a/docs/core-data-structures/session-query.md b/docs/core-data-structures/session-query.md index ded8ca3f7e..71fb956dcb 100644 --- a/docs/core-data-structures/session-query.md +++ b/docs/core-data-structures/session-query.md @@ -1,6 +1,6 @@ # Session Query -Exact reads over the live-preferred logical session corpus. The [package contract](../../packages/session-query/session-query) owns source precedence, dynamic optional persistence, cloning, surface classification, bounded windows, and typed failures. Full-text search is a separate proposed SQLite phase. +Exact reads and relationship traces over the live-preferred logical session corpus. The [package contract](../../packages/session-query/session-query) owns source precedence, dynamic optional persistence, cloning, surface classification, bounded windows, tracing validation, and typed failures. Full-text search is a separate proposed SQLite package. Source: [`packages/session-query/session-query/src/types.ts`](../../packages/session-query/session-query/src/types.ts) @@ -30,6 +30,34 @@ export interface SessionEventRecord { } ``` +## Session lineage + +`SessionLineageTrace` carries known parents in immediate-to-outward order and a forest of recursively nested direct descendants. The completeness discriminant makes a known root and a missing parent mutually exclusive. + +```ts type-equiv +export interface SessionLineageNode { + session: SessionRecord + descendants: SessionLineageNode[] +} +``` + +```ts type-equiv +export type SessionLineageTrace = { + target: SessionRecord + ancestors: SessionRecord[] + descendants: SessionLineageNode[] +} & ( + | { + complete: true + root: SessionRecord + } + | { + complete: false + unresolvedParentId: SessionId + } +) +``` + ## Bounded event reads The request addresses one raw seq and optional neighboring counts. The result carries a `SessionHeader` rather than availability flags so a known live target can remain independent of persistence health. @@ -53,6 +81,28 @@ export interface SessionEventWindow { } ``` +## Event relationships + +Event traces distinguish positional surface replacement from logged provenance. Every seq list contains direct links except `replacementChain`, which follows immediate replacers from the target to the final positional replacement. + +```ts type-equiv +export interface SessionEventTraceRequest { + sessionId: SessionId + seq: number +} +``` + +```ts type-equiv +export interface SessionEventTrace { + target: SessionEventRecord + replacedBy?: number + replacementChain: number[] + replacedEventSeqs: number[] + sourceEventSeqs: number[] + derivedEventSeqs: number[] +} +``` + ## Errors The closed code union distinguishes request validation, missing targets, malformed surface logs, optional-backend failure, and contradictory source metadata. @@ -61,6 +111,8 @@ The closed code union distinguishes request validation, missing targets, malform export type SessionQueryErrorCode = | 'SESSION_QUERY_EVENT_NOT_FOUND' | 'SESSION_QUERY_INVALID_CONFIG' + | 'SESSION_QUERY_INVALID_LINEAGE' + | 'SESSION_QUERY_INVALID_PROVENANCE' | 'SESSION_QUERY_INVALID_SURFACE' | 'SESSION_QUERY_INVALID_WINDOW' | 'SESSION_QUERY_PERSISTENCE_FAILED' diff --git a/docs/rfc/INDEX.md b/docs/rfc/INDEX.md index c202cc358c..d6bbed97ea 100644 --- a/docs/rfc/INDEX.md +++ b/docs/rfc/INDEX.md @@ -72,6 +72,7 @@ Generated by `pnpm run gen-rfc-index` from the RFC tree — never edit by hand; | [Repeat-tool-call guard plugin](implemented/feature/2026-07-08-repeat-tool-guard.md) | 2026-07-08 | | [The self-referential cordis toolset](implemented/feature/2026-07-08-self-referential-cordis-toolset.md) | 2026-07-08 | | [Exact session query service](implemented/feature/2026-07-10-session-query-service.md) | 2026-07-10 | +| [Session query relationship tracing](implemented/feature/2026-07-13-session-query-tracing.md) | 2026-07-13 | ### Simplification diff --git a/docs/rfc/implemented/feature/2026-07-10-session-query-service.md b/docs/rfc/implemented/feature/2026-07-10-session-query-service.md index 7e13256669..ebc201f102 100644 --- a/docs/rfc/implemented/feature/2026-07-10-session-query-service.md +++ b/docs/rfc/implemented/feature/2026-07-10-session-query-service.md @@ -4,13 +4,13 @@ Status: implemented ## Problem -Session history exists in two places: current `SessionStore` objects and an optional persistence backend. Consumers that need exact inspection would otherwise duplicate live-versus-persisted precedence, persistence lifecycle handling, raw-event surface classification, and defensive cloning. Durable state can lag the live log between checkpoints, so persistence alone is not a truthful current source. +Session history exists in two places: current `SessionStore` objects and an optional persistence backend. Consumers that need exact inspection would otherwise duplicate live-versus-persisted precedence, persistence lifecycle handling, raw-event surface classification, relationship tracing, and defensive cloning. Durable state can lag the live log between checkpoints, so persistence alone is not a truthful current source. Full-text search is related but materially larger. Designing provider registration, extraction, synchronization, invalidation, ranking, and cursor contracts before a real backend exists creates two speculative state machines: one in the interface service and another in the eventual database package. ## Decision -`@deepseek-ai/dsh-session-query` owns `ctx.sessionQuery`, a small trusted exact-read service over one logical corpus. It exposes `listSessions()`, `listEvents(sessionId)`, and bounded `readEvent(request)`. It does not expose filters, lineage or provenance traversals, text extractors, search requests, provider registration, or derived-index synchronization. +`@deepseek-ai/dsh-session-query` owns `ctx.sessionQuery`, a small trusted exact-inspection service over one logical corpus. It exposes `listSessions()`, `listEvents(sessionId)`, bounded `readEvent(request)`, `traceSession(sessionId)`, and `traceEvent(request)`. It does not expose filters, text extractors, search requests, provider registration, or derived-index synchronization. The separate [tracing decision](2026-07-13-session-query-tracing.md) owns lineage and event-relationship semantics. The service observes the optional `ctx.sessionPersistence` binding dynamically but retains no persisted cache or invalidation listener. Each cross-corpus list asks the active backend for authoritative metadata, then overlays a fresh live-store list. Matching ids become one `SessionRecord`: the live header wins and `live`/`persisted` independently report source availability. Immutable header disagreement is `SESSION_QUERY_SOURCE_CONFLICT`. @@ -18,13 +18,13 @@ An exact target read first checks the live store and snapshots the live header a ## Surface semantics -`dsh-session` exports `foldSurface(events)`, and `SurfaceManager` uses the same transition functions for its incremental cache. The fold returns detached current nodes and each replacement's actual removed seqs. `listEvents()` uses that result to classify every raw event as `current`, `shadowed`, or `log-only`, so inspection cannot disagree with model-history derivation about positional replacement semantics. +`dsh-session` exports `foldSurface(events)`, and `SurfaceManager` uses the same transition functions for its incremental cache. The fold returns detached current nodes and each replacement's actual removed seqs. `listEvents()` and `traceEvent()` use that result to classify every raw event, so inspection cannot disagree with model-history derivation about positional replacement semantics. `readEvent()` returns the complete target plus raw neighbors by contiguous seq. `before` and `after` default to zero and are independently bounded by `readWindowMax`, default 50. The result carries a cloned `SessionHeader`, not a source-availability record, because determining a live target's persisted flag would violate the guarantee that live exact reads do not depend on persistence health. ## Security boundary -The service is context-wide trusted infrastructure, not an authorization layer. A future model-facing history tool or human UI applies explicit caller/session scope. This phase adds no model-facing tool and changes no transcript or snapshot surface. +The service is context-wide trusted infrastructure, not an authorization layer. A future model-facing history tool or human UI applies explicit caller/session scope. The service adds no model-facing tool and changes no transcript or snapshot surface. ## Alternatives considered @@ -32,10 +32,9 @@ The service is context-wide trusted infrastructure, not an authorization layer. - **Query only persistence** — rejected because checkpoints can lag the current live log. - **Cache persisted metadata and listen for writes/removals** — rejected because exact reads can ask the authoritative sources directly, while cache invalidation adds lifecycle and concurrency state before scale requires it. - **Define a provider-neutral search protocol now** — rejected because no provider consumes it. The first SQLite FTS package should own one reconciliation/transaction state machine; a smaller shared seam can be extracted later only when a second implementation proves the boundary. -- **Include lineage, provenance, and generic filters in phase one** — rejected because no current consumer requires them and canonical logs remain sufficient to add them with evidence later. ## Consequences -Phase one has one source-resolution state variable: the currently mounted persistence service. There are no provider queues, fingerprints, extractor registries, observation generations, or derived index updates. Exact reads remain usable in live-only deployments and deterministic when persistence is present. +The service has one source-resolution state variable: the currently mounted persistence service. There are no provider queues, fingerprints, extractor registries, observation generations, or derived index updates. Exact reads and event traces remain usable in live-only deployments and deterministic when persistence is present. -Cross-corpus listing and persisted exact reads perform backend I/O on each call. That is deliberate: correctness comes from current authoritative state, and scale-oriented search belongs to the phase-two database. Full-text search is unavailable until that package defines and implements its complete contract. +Cross-corpus listing, lineage tracing, and persisted event operations perform backend I/O on each call. That is deliberate: correctness comes from current authoritative state, and scale-oriented search belongs to the proposed database package. Full-text search is unavailable until that package defines and implements its complete contract. diff --git a/docs/rfc/implemented/feature/2026-07-13-session-query-tracing.md b/docs/rfc/implemented/feature/2026-07-13-session-query-tracing.md new file mode 100644 index 0000000000..cb09531fc6 --- /dev/null +++ b/docs/rfc/implemented/feature/2026-07-13-session-query-tracing.md @@ -0,0 +1,34 @@ +# RFC: Session query relationship tracing + +Status: implemented + +## Problem + +Session relationships are encoded across immutable headers, positional surface operations, and logged provenance arrays. A consumer reconstructing those relationships directly would need to duplicate corpus precedence, surface folding, malformed-log handling, deterministic lineage ordering, and cloning. Positional replacement and provenance are different graphs, so collapsing them into one generic edge type would also lose meaning. + +## Decision + +`ctx.sessionQuery` exposes `traceSession(sessionId)` and `traceEvent({ sessionId, seq })` alongside its exact reads. Both are one-shot views over the existing live-preferred corpus: session tracing consumes one complete corpus listing, while event tracing consumes one loaded logical log and one canonical surface fold. The service retains no lineage, reverse-index, or replacement state after a call. + +`SessionLineageTrace` returns the target, known parents in immediate-to-outward order, and recursive descendant trees whose siblings sort by creation time and then session id. `complete: true` carries the known root; `complete: false` carries the first unresolved parent id. A cycle connected to the target fails with `SESSION_QUERY_INVALID_LINEAGE`. + +`SessionEventTrace` keeps positional and provenance relationships separate. `replacedBy` is the immediate positional replacer, `replacementChain` follows replacers to the final node, and `replacedEventSeqs` lists the actual surface nodes directly removed by the target. `sourceEventSeqs` preserves direct logged source order, while `derivedEventSeqs` lists later direct reverse references in log order. Provenance is not expanded transitively. + +## Validation boundary + +Event tracing checks target existence before surface analysis. Before returning a trace it validates the whole loaded log: only surface event types carry provenance, present arrays are nonempty and duplicate-free, every source is a known earlier seq, and every positional replacement names all surface nodes it removed. Provenance failures use `SESSION_QUERY_INVALID_PROVENANCE`; positional fold failures keep `SESSION_QUERY_INVALID_SURFACE`. `listEvents()` remains a surface-classification operation and does not acquire trace-specific provenance rejection. + +All returned records and arrays are detached. A known live event trace never consults persistence; persisted event traces preserve the exact-read list/load consistency check. Session lineage is necessarily a cross-corpus operation and therefore preserves cross-corpus persistence failure semantics. + +## Alternatives considered + +- **Expose standalone tracing helpers** — rejected because the source-precedence and detachment boundary belongs to `ctx.sessionQuery`; public helpers would invite callers to bypass it. +- **Combine replacement and provenance edges** — rejected because a positional replacement can shadow surface nodes while also citing non-surface construction inputs, and consumers need to distinguish those meanings. +- **Return transitive provenance closure** — rejected because it obscures logged direct evidence, increases result size, and lets one malformed distant edge alter otherwise local output. +- **Best-effort traces over malformed provenance** — rejected because a structurally plausible partial graph would look authoritative. Exact inspection fails loudly when the canonical relationship contract is broken. + +## Consequences + +Consumers receive deterministic relationship views without a cache or second corpus. Event tracing performs whole-log validation and allocation on each call, while lineage tracing lists the complete logical corpus on each call. Those costs keep the source of truth explicit and are separate from the content-bearing full-text-search and filtering API. + +The feature has unit and service-level coverage but no snapshot or end-to-end fixture because it introduces no model-facing consumer, transcript change, or cross-process protocol. diff --git a/docs/rfc/proposed/feature/2026-07-10-sqlite-session-query-provider.md b/docs/rfc/proposed/feature/2026-07-10-sqlite-session-query-provider.md index acfdf23bee..36216f74d8 100644 --- a/docs/rfc/proposed/feature/2026-07-10-sqlite-session-query-provider.md +++ b/docs/rfc/proposed/feature/2026-07-10-sqlite-session-query-provider.md @@ -4,7 +4,7 @@ Status: proposed ## Problem -The exact-read `ctx.sessionQuery` service deliberately has no derived index. Large persisted histories need full-text search without scanning every event on every query, while current live sessions need an overlay newer than the last durability checkpoint. Search also needs concrete ranking, snippets, filters, pagination, cancellation, and rebuild behavior. +The exact-read `ctx.sessionQuery` service deliberately has no derived index. Large persisted histories need full-text search without scanning every event on every query, while current live sessions need an overlay newer than the last durability checkpoint. Search also needs concrete ranking, snippets, pagination, cancellation, and rebuild behavior. Splitting those concerns across a speculative provider coordinator and a database implementation would create two coupled reconciliation state machines. The first real implementation should own the source observation, extraction, SQLite transaction, generation, and query as one lifecycle. @@ -20,7 +20,7 @@ Persisted documents survive restarts. Live overrides are connection-local and sh The implementation must define both cross-session and within-session scopes from executable use cases. Each searchable event is one document with session metadata, event metadata, surface classification, normalized semantic text, and a bounded plain-text snippet. Session results group by their strongest matching event; numeric backend scores remain private. -Filters compile to parameterized SQL before ranking. Query syntax is treated as data. Ordering includes stable tie fields. Opaque cursors bind to normalized request shape and the smallest relevant generation; unrelated session changes should not invalidate a within-session cursor. Cancellation must stop caller waiting and interrupt SQLite work where the runtime permits. +Search returns content-bearing result records rather than metadata-only headers. Chainable filters operate on that exact result shape and are designed and implemented with the search API instead of becoming a provider-specific pre-ranking contract. Query syntax is treated as data. Ordering includes stable tie fields. Opaque cursors bind to normalized request shape and the smallest relevant generation; unrelated session changes should not invalidate a within-session cursor. Cancellation must stop caller waiting and interrupt SQLite work where the runtime permits. Tokenizer choice remains an implementation experiment. FTS5 trigram supports substring recall but rejects useful terms shorter than three characters and increases index size; the proposal must benchmark that tradeoff against the default Unicode tokenizer before making it contract. @@ -41,7 +41,7 @@ Reconciliation may use stable fingerprints to avoid rewriting unchanged persiste - Restart tests cover unchanged, new, changed, and deleted persisted sessions without rebuilding the whole index. - Reopening preserves persisted rows and removes live rows; live rows shadow and then reveal their persisted base. -- Tests cover both search scopes, metadata filters, surface defaults, snippets, escaping, deterministic ties, pagination, scoped stale cursors, cancellation, dynamic persistence mount/unmount, and recovery after a failed transaction. +- Tests cover both search scopes, content-bearing results, chainable result filters, surface defaults, snippets, escaping, deterministic ties, pagination, scoped stale cursors, cancellation, dynamic persistence mount/unmount, and recovery after a failed transaction. - A schema mismatch resets only the derived database. - A keyless end-to-end test combines a real persistence backend with the real SQLite search package. - The RFC is amended to the measured tokenizer and public API actually implemented before moving to `implemented/`. diff --git a/packages/README.md b/packages/README.md index 64eaba8ad5..62dfe68fc8 100644 --- a/packages/README.md +++ b/packages/README.md @@ -25,7 +25,7 @@ Packages are grouped by modular role at `packages///`. The group dir | [`cordis/`](cordis/README.md) | Self-referential runtime toolset: inspect the live runtime's plugins and services, mount/unmount model-written plugins ([design](../docs/rfc/implemented/feature/2026-07-08-self-referential-cordis-toolset.md)) | Product — stable surface | | [`hooks/`](hooks/README.md) | Hook bridges + the shared Claude Code / Codex wire-protocol library | Product — stable surface | | [`session-persistence/`](session-persistence/README.md) | Persistence capability family: the seam + JSONL/SQLite backends | Product — stable surface | -| [`session-query/`](session-query/README.md) | Session retrieval family: logical corpus, surface records, and bounded exact reads | Product — stable surface | +| [`session-query/`](session-query/README.md) | Session retrieval family: logical corpus, exact reads, lineage, and event relationships | Product — stable surface | | [`ui/`](ui/README.md) | Editor/client integration surfaces: ACP bridge, app packages, user-approval and user-interaction seams, ask-user tool | Product — stable surface | | [`support/`](support/README.md) | Dev/test/example infrastructure (invariants, replay adapter, subagent mock) | Support — lower compatibility expectations | | [`util/`](util/README.md) | Low-level zero-dependency utilities shared across groups (the `Branded` primitive) | Support — small, stable, harness-dep-free | diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 80530025ad..3f491f8ad3 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -151,10 +151,12 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { key: 'sessionQuery', - summary: 'Live-preferred logical-corpus and exact-event read service.', + summary: 'Live-preferred logical-corpus exact-read and relationship-tracing service.', methods: [ 'listSessions(): Promise', 'async listEvents(sessionId: SessionId): Promise', + 'async traceSession(sessionId: SessionId): Promise', + 'async traceEvent(request: SessionEventTraceRequest): Promise', 'async readEvent(request: SessionEventReadRequest): Promise', ], }, @@ -780,6 +782,14 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SessionEventSurface', declaration: 'export type SessionEventSurface = \'current\' | \'shadowed\' | \'log-only\';', }, + { + name: 'SessionEventTrace', + declaration: 'export interface SessionEventTrace {\n target: SessionEventRecord;\n replacedBy?: number;\n replacementChain: number[];\n replacedEventSeqs: number[];\n sourceEventSeqs: number[];\n derivedEventSeqs: number[];\n}', + }, + { + name: 'SessionEventTraceRequest', + declaration: 'export interface SessionEventTraceRequest {\n sessionId: SessionId;\n seq: number;\n}', + }, { name: 'SessionEventType', declaration: 'export type SessionEventType = keyof SessionEventMap;', @@ -800,6 +810,14 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SessionId', declaration: 'export type SessionId = Branded<\'SessionId\'>;', }, + { + name: 'SessionLineageNode', + declaration: 'export interface SessionLineageNode {\n session: SessionRecord;\n descendants: SessionLineageNode[];\n}', + }, + { + name: 'SessionLineageTrace', + declaration: 'export type SessionLineageTrace = {\n target: SessionRecord;\n ancestors: SessionRecord[];\n descendants: SessionLineageNode[];\n} & ({\n complete: true;\n root: SessionRecord;\n} | {\n complete: false;\n unresolvedParentId: SessionId;\n});', + }, { name: 'SessionRecord', declaration: 'export interface SessionRecord {\n header: SessionHeader;\n live: boolean;\n persisted: boolean;\n}', diff --git a/packages/session-query/README.md b/packages/session-query/README.md index 8b0c06a30c..4c4b1c75c4 100644 --- a/packages/session-query/README.md +++ b/packages/session-query/README.md @@ -1,9 +1,9 @@ # session-query/ — session retrieval capability family -Trusted exact reads over live and durable session logs. Phase one contains one interface package that owns `ctx.sessionQuery`, logical-corpus precedence, surface classification, and bounded event reads. +Trusted exact reads and relationship traces over live and durable session logs. The family contains one interface package that owns `ctx.sessionQuery`, logical-corpus precedence, surface classification, bounded event reads, lineage, and direct event relationships. | Package | Role | ctx key | |---|---|---| -| [`session-query/`](session-query/README.md) | Logical-corpus and exact-event read service | `ctx.sessionQuery` | +| [`session-query/`](session-query/README.md) | Logical-corpus exact-read and relationship-tracing service | `ctx.sessionQuery` | -The family is independent of compaction: it reads the canonical session log but does not participate in compaction policy or execution. Full-text search remains proposed as a phase-two SQLite package rather than a speculative provider seam in this interface package. +The family is independent of compaction: it reads canonical lineage, surface operations, and logged provenance but does not participate in compaction policy or execution. Full-text search remains a proposed SQLite package rather than a speculative provider seam in this interface package. diff --git a/packages/session-query/session-query/README.md b/packages/session-query/session-query/README.md index 55f9b32fcd..f85ade5984 100644 --- a/packages/session-query/session-query/README.md +++ b/packages/session-query/session-query/README.md @@ -1,6 +1,6 @@ # @deepseek-ai/dsh-session-query -Exact session-history retrieval through `ctx.sessionQuery`. The service presents live `ctx.sessions` and an optional, dynamically mounted `ctx.sessionPersistence` as one logical corpus. Matching ids produce one record: live events win, while `live` and `persisted` report both source availabilities. Conflicting immutable headers fail with `SESSION_QUERY_SOURCE_CONFLICT`. +Exact session-history retrieval and relationship tracing through `ctx.sessionQuery`. The service presents live `ctx.sessions` and an optional, dynamically mounted `ctx.sessionPersistence` as one logical corpus. Matching ids produce one record: live events win, while `live` and `persisted` report both source availabilities. Conflicting immutable headers fail with `SESSION_QUERY_SOURCE_CONFLICT`. This is trusted context-wide infrastructure. It performs no caller authorization; a future model tool or UI must constrain which sessions its caller may inspect. @@ -9,10 +9,14 @@ This is trusted context-wide infrastructure. It performs no caller authorization - `listSessions()` reads current persistence metadata, merges live records with live precedence, and returns cloned records in deterministic newest-first order. - `listEvents(sessionId)` loads the live-preferred raw log and classifies each event as `current`, `shadowed`, or `log-only` with the shared `dsh-session` surface fold. - `readEvent(request)` returns a cloned header, the full target event, and a bounded raw-seq window. `before` and `after` default to zero and may not exceed `readWindowMax`. +- `traceSession(sessionId)` reads the corpus once and returns immediate-to-outward ancestors plus deterministic recursive descendant trees. `complete: false` identifies the first missing parent; a target-connected cycle fails with `SESSION_QUERY_INVALID_LINEAGE`. +- `traceEvent(request)` loads the logical log once and returns direct positional replacements and direct logged provenance. `replacementChain` follows positional replacers to the final replacement; provenance links remain non-transitive. -Persistence is optional and may mount or unmount dynamically. A cross-corpus list fails with `SESSION_QUERY_PERSISTENCE_FAILED` while mounted persistence is unreadable. A read targeting a known live session does not consult persistence, so durable backend health cannot make current in-memory history unreadable. Persisted exact reads list before loading, and reject a metadata mismatch rather than combining inconsistent observations. +Persistence is optional and may mount or unmount dynamically. Cross-corpus listing and lineage tracing fail with `SESSION_QUERY_PERSISTENCE_FAILED` while mounted persistence is unreadable. An event read or trace targeting a known live session does not consult persistence, so durable backend health cannot make current in-memory history unreadable. Persisted event operations list before loading and reject a metadata mismatch rather than combining inconsistent observations. -`SessionQueryError.code` is a closed union: `SESSION_QUERY_EVENT_NOT_FOUND`, `SESSION_QUERY_INVALID_CONFIG`, `SESSION_QUERY_INVALID_SURFACE`, `SESSION_QUERY_INVALID_WINDOW`, `SESSION_QUERY_PERSISTENCE_FAILED`, `SESSION_QUERY_SESSION_NOT_FOUND`, and `SESSION_QUERY_SOURCE_CONFLICT`. +`traceEvent()` validates the whole loaded log before returning relationships: provenance arrays are nonempty and duplicate-free, references name known earlier events, only surface event types carry sources, and each positional replacement names every surface node it removed. Provenance violations fail with `SESSION_QUERY_INVALID_PROVENANCE`; positional fold failures remain `SESSION_QUERY_INVALID_SURFACE`. `listEvents()` only needs surface classification and deliberately does not enforce the trace-specific provenance contract. + +`SessionQueryError.code` is a closed union: `SESSION_QUERY_EVENT_NOT_FOUND`, `SESSION_QUERY_INVALID_CONFIG`, `SESSION_QUERY_INVALID_LINEAGE`, `SESSION_QUERY_INVALID_PROVENANCE`, `SESSION_QUERY_INVALID_SURFACE`, `SESSION_QUERY_INVALID_WINDOW`, `SESSION_QUERY_PERSISTENCE_FAILED`, `SESSION_QUERY_SESSION_NOT_FOUND`, and `SESSION_QUERY_SOURCE_CONFLICT`. ## Configuration @@ -20,4 +24,4 @@ Persistence is optional and may mount or unmount dynamically. A cross-corpus lis |---|---:|---| | `readWindowMax` | `50` | Maximum `before` or `after` raw-event count. | -This phase deliberately has no filters, lineage/provenance traversal, extraction registry, search-provider protocol, index synchronization, or model-facing tool. Full-text search belongs beside its first real implementation; the proposed SQLite package and its single transaction/reconciliation owner are described in the [phase-two RFC](../../../docs/rfc/proposed/feature/2026-07-10-sqlite-session-query-provider.md). +The service has no filters, extraction registry, search-provider protocol, index synchronization, or model-facing tool. The [tracing decision](../../../docs/rfc/implemented/feature/2026-07-13-session-query-tracing.md) owns relationship semantics. Content-bearing full-text-search results and their chainable filters belong together in the proposed [SQLite search package](../../../docs/rfc/proposed/feature/2026-07-10-sqlite-session-query-provider.md). diff --git a/packages/session-query/session-query/package.json b/packages/session-query/session-query/package.json index 9f78d4f1db..d096058fa5 100644 --- a/packages/session-query/session-query/package.json +++ b/packages/session-query/session-query/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-session-query", - "description": "Live-preferred exact session-history retrieval service (ctx.sessionQuery)", + "description": "Live-preferred exact session-history retrieval and tracing service (ctx.sessionQuery)", "version": "0.0.1", "private": true, "type": "module", diff --git a/packages/session-query/session-query/src/config.ts b/packages/session-query/session-query/src/config.ts index 2736f68cbd..a6d0ab10fa 100644 --- a/packages/session-query/session-query/src/config.ts +++ b/packages/session-query/session-query/src/config.ts @@ -5,16 +5,18 @@ import { HarnessError } from '@deepseek-ai/dsh-llm' /** Default maximum `before`/`after` raw-event window. */ export const SESSION_QUERY_READ_WINDOW_MAX = 50 -/** Configuration for exact session-query reads. */ +/** Configuration for exact session-query reads and traces. */ export interface Config { /** Maximum accepted raw read context on either side. Defaults to 50. */ readWindowMax?: number } -/** Stable machine-routable failure taxonomy for exact session reads. */ +/** Stable machine-routable failure taxonomy for exact session reads and traces. */ export type SessionQueryErrorCode = | 'SESSION_QUERY_EVENT_NOT_FOUND' | 'SESSION_QUERY_INVALID_CONFIG' + | 'SESSION_QUERY_INVALID_LINEAGE' + | 'SESSION_QUERY_INVALID_PROVENANCE' | 'SESSION_QUERY_INVALID_SURFACE' | 'SESSION_QUERY_INVALID_WINDOW' | 'SESSION_QUERY_PERSISTENCE_FAILED' diff --git a/packages/session-query/session-query/src/index.ts b/packages/session-query/session-query/src/index.ts index 828fe2ec88..c468f31696 100644 --- a/packages/session-query/session-query/src/index.ts +++ b/packages/session-query/session-query/src/index.ts @@ -1,17 +1,19 @@ /** - * Exact session-history reads over live and optionally persisted logs. + * Exact session-history reads and traces over live and optionally persisted logs. * * @module @deepseek-ai/dsh-session-query */ import { Context, Service } from 'cordis' import z from 'schemastery' -import { foldSurface } from '@deepseek-ai/dsh-session' -import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session' +import type { SessionId } from '@deepseek-ai/dsh-session' import type { SessionEventReadRequest, SessionEventRecord, + SessionEventTrace, + SessionEventTraceRequest, SessionEventWindow, + SessionLineageTrace, SessionRecord, } from './types.ts' import { @@ -20,6 +22,7 @@ import { type Config, } from './config.ts' import { SessionCorpus } from './corpus.ts' +import { eventRecords, traceEventLog, traceLineage } from './tracing.ts' export type * from './types.ts' export type { Config, SessionQueryErrorCode } from './config.ts' @@ -31,7 +34,7 @@ declare module 'cordis' { } } -/** Live-preferred logical-corpus and exact-event read service. */ +/** Live-preferred logical-corpus exact-read and relationship-tracing service. */ export class SessionQueryService extends Service { static inject = ['sessions'] static Config: z = z.object({ @@ -71,6 +74,26 @@ export class SessionQueryService extends Service { return eventRecords(sessionId, loaded.events) } + /** + * Trace known ancestry and descendants from one corpus observation. + * @param sessionId - logical session id to trace. + * @returns a complete lineage or an explicit unresolved parent boundary. + */ + async traceSession(sessionId: SessionId): Promise { + const records = await this._corpus.listSessions() + return traceLineage(records, sessionId) + } + + /** + * Trace one event's direct positional and provenance relationships. + * @param request - target session id and event seq. + * @returns direct links plus the target's positional replacement chain. + */ + async traceEvent(request: SessionEventTraceRequest): Promise { + const loaded = await this._corpus.load(request.sessionId) + return traceEventLog(request.sessionId, loaded.events, request.seq) + } + /** * Read one full event plus a bounded raw-log context window. * @param request - target session/seq and context sizes. @@ -110,27 +133,4 @@ export class SessionQueryService extends Service { } } -function eventRecords(sessionId: SessionId, events: readonly SessionEvent[]): SessionEventRecord[] { - let folded: ReturnType - try { - folded = foldSurface(events) - } catch (error: unknown) { - throw new SessionQueryError( - /* v8 ignore next -- foldSurface throws Error instances */ - `invalid session surface: ${error instanceof Error ? error.message : 'unknown error'}`, - 'SESSION_QUERY_INVALID_SURFACE', - { cause: error }, - ) - } - const current = new Set(folded.nodes.map(node => node.seq)) - const shadowed = new Set(folded.replacements.flatMap(replacement => replacement.shadowedSeqs)) - return events.map(event => ({ - sessionId, - seq: event.seq, - type: event.type, - time: event.time, - surface: current.has(event.seq) ? 'current' : shadowed.has(event.seq) ? 'shadowed' : 'log-only', - })) -} - export default SessionQueryService diff --git a/packages/session-query/session-query/src/tracing.ts b/packages/session-query/session-query/src/tracing.ts new file mode 100644 index 0000000000..8efa2922b6 --- /dev/null +++ b/packages/session-query/session-query/src/tracing.ts @@ -0,0 +1,277 @@ +/** One-shot session-lineage and event-relationship tracing helpers. */ + +import { foldSurface, isSurfaceEligibleType } from '@deepseek-ai/dsh-session' +import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session' +import { SessionQueryError } from './config.ts' +import type { + SessionEventRecord, + SessionEventTrace, + SessionLineageNode, + SessionLineageTrace, + SessionRecord, +} from './types.ts' + +interface EventLogAnalysis { + records: SessionEventRecord[] + replacedBy: Map + replacedEventSeqs: Map +} + +/** + * Classify a raw event log with one canonical surface fold. + * @param sessionId - owner of the event log. + * @param events - detached raw event log. + * @returns lightweight records in ascending log order. + */ +export function eventRecords( + sessionId: SessionId, + events: readonly SessionEvent[], +): SessionEventRecord[] { + return analyzeEventLog(sessionId, events).records +} + +/** + * Trace one target after one canonical surface fold and whole-log validation. + * @param sessionId - owner of the event log. + * @param events - detached raw event log. + * @param seq - target event seq. + * @returns direct surface and provenance relationships. + */ +export function traceEventLog( + sessionId: SessionId, + events: readonly SessionEvent[], + seq: number, +): SessionEventTrace { + const target = events[seq] + if (target === undefined || target.seq !== seq) { + throw new SessionQueryError( + `session "${sessionId}" has no event at seq ${seq}`, + 'SESSION_QUERY_EVENT_NOT_FOUND', + ) + } + + const analysis = analyzeEventLog(sessionId, events) + validateProvenance(events, analysis.replacedEventSeqs) + + const replacementChain: number[] = [] + let replacement = analysis.replacedBy.get(seq) + while (replacement !== undefined) { + replacementChain.push(replacement) + replacement = analysis.replacedBy.get(replacement) + } + + const sourceEventSeqs = eventSources(target) + const derivedEventSeqs: number[] = [] + for (const event of events) { + if (event.seq <= seq) continue + if (eventSources(event).includes(seq)) derivedEventSeqs.push(event.seq) + } + + // The target check above proves the parallel record exists at this index. + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const targetRecord = analysis.records[seq]! + const replacedBy = analysis.replacedBy.get(seq) + return { + target: { ...targetRecord }, + ...replacedBy === undefined ? {} : { replacedBy }, + replacementChain, + replacedEventSeqs: [...(analysis.replacedEventSeqs.get(seq) ?? [])], + sourceEventSeqs: [...sourceEventSeqs], + derivedEventSeqs, + } +} + +/** + * Trace one target's known ancestry and recursively known descendants. + * @param records - complete logical corpus from one observation. + * @param sessionId - target session id. + * @returns complete or explicitly partial lineage. + */ +export function traceLineage( + records: readonly SessionRecord[], + sessionId: SessionId, +): SessionLineageTrace { + const byId = new Map(records.map(record => [record.header.id, record])) + const target = byId.get(sessionId) + if (target === undefined) { + throw new SessionQueryError( + `session "${sessionId}" not found`, + 'SESSION_QUERY_SESSION_NOT_FOUND', + ) + } + + const ancestors: SessionRecord[] = [] + const ancestrySeen = new Set([sessionId]) + let unresolvedParentId: SessionId | undefined + let parentId = target.header.parentSession + while (parentId !== undefined) { + if (ancestrySeen.has(parentId)) lineageCycle(parentId) + ancestrySeen.add(parentId) + const parent = byId.get(parentId) + if (parent === undefined) { + unresolvedParentId = parentId + break + } + ancestors.push(parent) + parentId = parent.header.parentSession + } + + const childrenByParent = new Map() + for (const record of records) { + const parent = record.header.parentSession + if (parent === undefined) continue + const children = childrenByParent.get(parent) ?? [] + children.push(record) + childrenByParent.set(parent, children) + } + for (const children of childrenByParent.values()) children.sort(compareSessionsAscending) + + const descendants = buildDescendants(childrenByParent, sessionId) + const common = { + target: cloneRecord(target), + ancestors: ancestors.map(cloneRecord), + descendants, + } + if (unresolvedParentId !== undefined) { + return { ...common, complete: false, unresolvedParentId } + } + return { + ...common, + complete: true, + root: cloneRecord(ancestors.at(-1) ?? target), + } +} + +function analyzeEventLog( + sessionId: SessionId, + events: readonly SessionEvent[], +): EventLogAnalysis { + const folded = safeFold(events) + const current = new Set(folded.nodes.map(node => node.seq)) + const shadowed = new Set() + const replacedBy = new Map() + const replacedEventSeqs = new Map() + for (const replacement of folded.replacements) { + const removed = [...replacement.shadowedSeqs] + replacedEventSeqs.set(replacement.seq, removed) + for (const removedSeq of removed) { + shadowed.add(removedSeq) + replacedBy.set(removedSeq, replacement.seq) + } + } + return { + records: events.map(event => ({ + sessionId, + seq: event.seq, + type: event.type, + time: event.time, + surface: current.has(event.seq) + ? 'current' + : shadowed.has(event.seq) ? 'shadowed' : 'log-only', + })), + replacedBy, + replacedEventSeqs, + } +} + +function validateProvenance( + events: readonly SessionEvent[], + replacedEventSeqs: ReadonlyMap, +): void { + for (const event of events) { + const sources = rawEventSources(event) + if (sources === undefined) continue + if (!isSurfaceEligibleType(event.type)) { + invalidProvenance(`non-surface event at seq ${event.seq} carries sourceEventSeqs`) + } + if (!Array.isArray(sources) || sources.length === 0) { + invalidProvenance(`event at seq ${event.seq} has an empty or invalid sourceEventSeqs`) + } + const unique = new Set() + for (const source of sources as unknown[]) { + if (unique.has(source)) { + invalidProvenance(`event at seq ${event.seq} repeats source seq ${String(source)}`) + } + unique.add(source) + if ( + typeof source !== 'number' + || !Number.isInteger(source) + || source < 0 + || source >= event.seq + || events[source]?.seq !== source + ) { + invalidProvenance(`event at seq ${event.seq} references unknown or non-earlier source seq ${String(source)}`) + } + } + } + + for (const [replacementSeq, removedSeqs] of replacedEventSeqs) { + // The fold reports only replacement events from the input log. + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const replacement = events.find(event => event.seq === replacementSeq)! + const sources = rawEventSources(replacement) + if (!Array.isArray(sources)) { + invalidProvenance(`replacement at seq ${replacementSeq} omits its shadowed surface sources`) + } + const sourceSet = new Set(sources as unknown[]) + for (const removedSeq of removedSeqs) { + if (!sourceSet.has(removedSeq)) { + invalidProvenance(`replacement at seq ${replacementSeq} omits shadowed surface seq ${removedSeq}`) + } + } + } +} + +function rawEventSources(event: SessionEvent): unknown { + return (event as SessionEvent & { sourceEventSeqs?: unknown }).sourceEventSeqs +} + +function eventSources(event: SessionEvent): number[] { + const sources = rawEventSources(event) + return Array.isArray(sources) ? sources as number[] : [] +} + +function safeFold(events: readonly SessionEvent[]): ReturnType { + try { + return foldSurface(events) + } catch (error: unknown) { + throw new SessionQueryError( + /* v8 ignore next -- foldSurface throws Error instances */ + `invalid session surface: ${error instanceof Error ? error.message : 'unknown error'}`, + 'SESSION_QUERY_INVALID_SURFACE', + { cause: error }, + ) + } +} + +function buildDescendants( + childrenByParent: ReadonlyMap, + sessionId: SessionId, +): SessionLineageNode[] { + return (childrenByParent.get(sessionId) ?? []).map(child => ({ + session: cloneRecord(child), + descendants: buildDescendants(childrenByParent, child.header.id), + })) +} + +function compareSessionsAscending(a: SessionRecord, b: SessionRecord): number { + return a.header.createdAt - b.header.createdAt || a.header.id.localeCompare(b.header.id) +} + +function cloneRecord(record: SessionRecord): SessionRecord { + return { ...record, header: structuredClone(record.header) } +} + +function lineageCycle(id: SessionId): never { + throw new SessionQueryError( + `session lineage contains a cycle at "${id}"`, + 'SESSION_QUERY_INVALID_LINEAGE', + ) +} + +function invalidProvenance(message: string): never { + throw new SessionQueryError( + `invalid session provenance: ${message}`, + 'SESSION_QUERY_INVALID_PROVENANCE', + ) +} diff --git a/packages/session-query/session-query/src/types.ts b/packages/session-query/session-query/src/types.ts index 5c49695dda..38f0225ee4 100644 --- a/packages/session-query/session-query/src/types.ts +++ b/packages/session-query/session-query/src/types.ts @@ -1,5 +1,6 @@ /** - * Public records for exact reads over the live-preferred logical session corpus. + * Public records for exact reads and relationship traces over the + * live-preferred logical session corpus. * * @module @deepseek-ai/dsh-session-query/types */ @@ -33,6 +34,61 @@ export interface SessionEventRecord { surface: SessionEventSurface } +/** Recursive descendant node in a session-lineage trace. */ +export interface SessionLineageNode { + /** Detached logical-corpus record for this descendant. */ + session: SessionRecord + /** Direct children, each carrying its own recursive descendants. */ + descendants: SessionLineageNode[] +} + +/** Known ancestry and descendants for one logical session. */ +export type SessionLineageTrace = { + /** Detached record for the session that was traced. */ + target: SessionRecord + /** Known parents from the immediate parent outward. */ + ancestors: SessionRecord[] + /** Complete known descendant trees rooted at the target's direct children. */ + descendants: SessionLineageNode[] +} & ( + | { + /** The complete parent chain is present in the logical corpus. */ + complete: true + /** Detached record at the top of the complete lineage. */ + root: SessionRecord + } + | { + /** The parent chain leaves the visible logical corpus. */ + complete: false + /** First parent id that is not present in the logical corpus. */ + unresolvedParentId: SessionId + } +) + +/** Request for direct surface and provenance relationships around one event. */ +export interface SessionEventTraceRequest { + /** Session that owns the target event. */ + sessionId: SessionId + /** Target event seq. */ + seq: number +} + +/** Direct surface and provenance relationships for one event. */ +export interface SessionEventTrace { + /** Lightweight target record. */ + target: SessionEventRecord + /** Immediate positional replacement event, when the target was shadowed. */ + replacedBy?: number + /** Positional replacers from the immediate replacement to the final replacement. */ + replacementChain: number[] + /** Surface nodes directly removed when the target itself performed a replacement. */ + replacedEventSeqs: number[] + /** Direct logged provenance sources in their recorded order. */ + sourceEventSeqs: number[] + /** Later events that directly name the target as a provenance source, in log order. */ + derivedEventSeqs: number[] +} + /** Request for one event plus raw neighboring log context. */ export interface SessionEventReadRequest { /** Session that owns the target event. */ diff --git a/packages/session-query/session-query/tests/tracing.spec.ts b/packages/session-query/session-query/tests/tracing.spec.ts new file mode 100644 index 0000000000..3128cb243f --- /dev/null +++ b/packages/session-query/session-query/tests/tracing.spec.ts @@ -0,0 +1,376 @@ +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import SessionStore, { SESSION_FORMAT_VERSION, SessionId } from '@deepseek-ai/dsh-session' +import type { Session, SessionEvent, SessionHeader, SessionId as SessionIdType } from '@deepseek-ai/dsh-session' +import SessionPersistence from '@deepseek-ai/dsh-session-persistence' +import SessionQueryService, { type SessionQueryErrorCode } from '@deepseek-ai/dsh-session-query' + +function header(id: string, createdAt = 1, extra: Partial = {}): SessionHeader { + return { version: SESSION_FORMAT_VERSION, id: SessionId(id), createdAt, ...extra } +} + +function appendEvent(seq: number, sources?: number[]): SessionEvent { + return { + type: 'user/message', + seq, + time: seq + 1, + data: { content: [{ type: 'text', text: `event ${seq}` }], source: { kind: 'user' } }, + surfaceOp: 'append', + ...sources === undefined ? {} : { sourceEventSeqs: sources }, + } +} + +class TracePersistence extends SessionPersistence { + static entries = new Map() + static listCalls = 0 + static loadCalls = 0 + static listFailure: Error | undefined + static loadFailure: Error | undefined + static afterList: (() => void) | undefined + + static reset(entries: readonly { meta: SessionHeader; events: SessionEvent[] }[] = []): void { + this.entries = new Map(entries.map(entry => [entry.meta.id, structuredClone(entry)])) + this.listCalls = 0 + this.loadCalls = 0 + this.listFailure = undefined + this.loadFailure = undefined + this.afterList = undefined + } + + create(meta: SessionHeader): Promise { + TracePersistence.entries.set(meta.id, { meta: structuredClone(meta), events: [] }) + return Promise.resolve() + } + + append(id: SessionIdType, events: readonly SessionEvent[]): Promise { + const entry = TracePersistence.entries.get(id) + if (entry === undefined) return Promise.reject(new Error('missing test session')) + entry.events.push(...structuredClone(events)) + return Promise.resolve() + } + + load(id: SessionIdType): Promise<{ meta: SessionHeader; events: SessionEvent[] }> { + TracePersistence.loadCalls += 1 + if (TracePersistence.loadFailure !== undefined) return Promise.reject(TracePersistence.loadFailure) + const entry = TracePersistence.entries.get(id) + if (entry === undefined) return Promise.reject(new Error('missing test session')) + return Promise.resolve(structuredClone(entry)) + } + + list(): Promise { + TracePersistence.listCalls += 1 + if (TracePersistence.listFailure !== undefined) return Promise.reject(TracePersistence.listFailure) + const result = [...TracePersistence.entries.values()].map(entry => structuredClone(entry.meta)) + TracePersistence.afterList?.() + return Promise.resolve(result) + } +} + +async function queryContext(): Promise { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(SessionQueryService) + return ctx +} + +function expectCode(code: SessionQueryErrorCode): Error { + return expect.objectContaining({ code }) as Error +} + +function appendTraceEvents(session: Session): void { + session.append('assistant/chunk', { + turn: 1, + step: 1, + chunk: { type: 'text-delta', index: 0, text: 'draft' }, + }) + session.append( + 'user/message', + { content: [{ type: 'text', text: 'original' }], source: { kind: 'user' } }, + { surfaceOp: 'append', sourceEventSeqs: [0] }, + ) + session.append( + 'assistant/message', + { turn: 1, step: 1, content: [{ type: 'text', text: 'summary one' }] }, + { surfaceOp: { op: 'replace', start: 1, end: 1 }, sourceEventSeqs: [1, 0] }, + ) + session.append( + 'context/message', + { content: [{ type: 'text', text: 'context' }], source: { kind: 'plugin', plugin: 'test' } }, + { surfaceOp: 'append' }, + ) + session.append( + 'assistant/message', + { turn: 1, step: 2, content: [{ type: 'text', text: 'summary two' }] }, + { surfaceOp: { op: 'replace', start: 2, end: 2 }, sourceEventSeqs: [0, 2] }, + ) +} + +describe('session lineage tracing', () => { + it('returns complete ancestry, deterministic descendant trees, and detached records', async () => { + const ctx = await queryContext() + const root = ctx.sessions.create(SessionId('root'), { meta: { createdAt: 0 } }) + const parent = ctx.sessions.create(SessionId('parent'), { + meta: { createdAt: 1, parentSession: root.id }, + }) + const target = ctx.sessions.create(SessionId('target'), { + meta: { createdAt: 2, parentSession: parent.id }, + }) + ctx.sessions.create(SessionId('b'), { meta: { createdAt: 4, parentSession: target.id } }) + const childA = ctx.sessions.create(SessionId('a'), { + meta: { createdAt: 4, parentSession: target.id }, + }) + ctx.sessions.create(SessionId('older'), { meta: { createdAt: 3, parentSession: target.id } }) + ctx.sessions.create(SessionId('grandchild'), { + meta: { createdAt: 5, parentSession: childA.id }, + }) + + const trace = await ctx.sessionQuery.traceSession(target.id) + expect(trace.complete).toBe(true) + if (!trace.complete) throw new Error('expected complete lineage') + expect(trace.ancestors.map(record => record.header.id)).toEqual([parent.id, root.id]) + expect(trace.root.header.id).toBe(root.id) + expect(trace.descendants.map(node => node.session.header.id)) + .toEqual([SessionId('older'), SessionId('a'), SessionId('b')]) + expect(trace.descendants[1]?.descendants.map(node => node.session.header.id)) + .toEqual([SessionId('grandchild')]) + + trace.target.header.createdAt = 99 + trace.ancestors[0]!.header.createdAt = 99 + trace.root.header.createdAt = 99 + trace.descendants[0]!.session.header.createdAt = 99 + const repeated = await ctx.sessionQuery.traceSession(target.id) + expect(repeated.target.header.createdAt).toBe(2) + expect(repeated.ancestors[0]?.header.createdAt).toBe(1) + expect(repeated.descendants[0]?.session.header.createdAt).toBe(3) + }) + + it('represents root and unresolved-parent traces explicitly', async () => { + const ctx = await queryContext() + const root = ctx.sessions.create(SessionId('root'), { meta: { createdAt: 1 } }) + const partial = ctx.sessions.create(SessionId('partial'), { + meta: { createdAt: 2, parentSession: SessionId('outside') }, + }) + + await expect(ctx.sessionQuery.traceSession(root.id)).resolves.toMatchObject({ + complete: true, + root: { header: { id: root.id } }, + ancestors: [], + }) + await expect(ctx.sessionQuery.traceSession(partial.id)).resolves.toMatchObject({ + complete: false, + unresolvedParentId: SessionId('outside'), + ancestors: [], + }) + }) + + it('rejects target-connected cycles and missing targets', async () => { + const ctx = await queryContext() + ctx.sessions.create(SessionId('a'), { + meta: { createdAt: 1, parentSession: SessionId('b') }, + }) + ctx.sessions.create(SessionId('b'), { + meta: { createdAt: 2, parentSession: SessionId('a') }, + }) + + await expect(ctx.sessionQuery.traceSession(SessionId('a'))) + .rejects.toThrow(expectCode('SESSION_QUERY_INVALID_LINEAGE')) + await expect(ctx.sessionQuery.traceSession(SessionId('missing'))) + .rejects.toThrow(expectCode('SESSION_QUERY_SESSION_NOT_FOUND')) + }) + + it('uses one cross-corpus observation and preserves persistence failure semantics', async () => { + const durable = header('durable') + TracePersistence.reset([{ meta: durable, events: [appendEvent(0)] }]) + const ctx = await queryContext() + await ctx.plugin(TracePersistence) + + await expect(ctx.sessionQuery.traceSession(durable.id)).resolves.toMatchObject({ + target: { live: false, persisted: true }, + complete: true, + }) + expect(TracePersistence.listCalls).toBe(1) + expect(TracePersistence.loadCalls).toBe(0) + + TracePersistence.listFailure = new Error('unavailable') + await expect(ctx.sessionQuery.traceSession(durable.id)) + .rejects.toThrow(expectCode('SESSION_QUERY_PERSISTENCE_FAILED')) + }) +}) + +describe('session event tracing', () => { + it('returns direct replacement and provenance links in their contract order', async () => { + const ctx = await queryContext() + const session = ctx.sessions.create(SessionId('trace')) + appendTraceEvents(session) + + const original = await ctx.sessionQuery.traceEvent({ sessionId: session.id, seq: 1 }) + expect(original.target).toMatchObject({ + sessionId: session.id, + seq: 1, + type: 'user/message', + surface: 'shadowed', + }) + expect(original).toMatchObject({ + replacedBy: 2, + replacementChain: [2, 4], + replacedEventSeqs: [], + sourceEventSeqs: [0], + derivedEventSeqs: [2], + }) + await expect(ctx.sessionQuery.traceEvent({ sessionId: session.id, seq: 2 })) + .resolves.toMatchObject({ + replacedBy: 4, + replacementChain: [4], + replacedEventSeqs: [1], + sourceEventSeqs: [1, 0], + derivedEventSeqs: [4], + }) + await expect(ctx.sessionQuery.traceEvent({ sessionId: session.id, seq: 0 })) + .resolves.toMatchObject({ + target: { surface: 'log-only' }, + replacementChain: [], + sourceEventSeqs: [], + derivedEventSeqs: [1, 2, 4], + }) + await expect(ctx.sessionQuery.traceEvent({ sessionId: session.id, seq: 4 })) + .resolves.toMatchObject({ + replacementChain: [], + replacedEventSeqs: [2], + sourceEventSeqs: [0, 2], + derivedEventSeqs: [], + }) + }) + + it('returns fresh trace arrays and target records', async () => { + const ctx = await queryContext() + const session = ctx.sessions.create(SessionId('detached')) + appendTraceEvents(session) + + const first = await ctx.sessionQuery.traceEvent({ sessionId: session.id, seq: 2 }) + first.target.time = -1 + first.replacementChain.push(99) + first.replacedEventSeqs.push(99) + first.sourceEventSeqs.push(99) + first.derivedEventSeqs.push(99) + const repeated = await ctx.sessionQuery.traceEvent({ sessionId: session.id, seq: 2 }) + expect(repeated.target.time).not.toBe(-1) + expect(repeated.replacementChain).toEqual([4]) + expect(repeated.replacedEventSeqs).toEqual([1]) + expect(repeated.sourceEventSeqs).toEqual([1, 0]) + expect(repeated.derivedEventSeqs).toEqual([4]) + }) + + it('loads persisted logs once, prefers live logs, and preserves failures and conflicts', async () => { + const durable = header('shared', 1, { cwd: '/same' }) + TracePersistence.reset([{ meta: durable, events: [appendEvent(0)] }]) + const ctx = await queryContext() + await ctx.plugin(TracePersistence) + + await expect(ctx.sessionQuery.traceEvent({ sessionId: durable.id, seq: 0 })) + .resolves.toMatchObject({ target: { type: 'user/message', surface: 'current' } }) + expect([TracePersistence.listCalls, TracePersistence.loadCalls]).toEqual([1, 1]) + + const live = ctx.sessions.create(durable.id, { meta: { createdAt: 1, cwd: '/same' } }) + live.append( + 'context/message', + { content: [{ type: 'text', text: 'live' }], source: { kind: 'plugin', plugin: 'test' } }, + { surfaceOp: 'append' }, + ) + TracePersistence.listFailure = new Error('list unavailable') + TracePersistence.loadFailure = new Error('load unavailable') + await expect(ctx.sessionQuery.traceEvent({ sessionId: durable.id, seq: 0 })) + .resolves.toMatchObject({ target: { type: 'context/message' } }) + expect([TracePersistence.listCalls, TracePersistence.loadCalls]).toEqual([1, 1]) + + TracePersistence.reset([{ meta: durable, events: [appendEvent(0)] }]) + const failedCtx = await queryContext() + await failedCtx.plugin(TracePersistence) + TracePersistence.listFailure = new Error('list unavailable') + await expect(failedCtx.sessionQuery.traceEvent({ sessionId: durable.id, seq: 0 })) + .rejects.toThrow(expectCode('SESSION_QUERY_PERSISTENCE_FAILED')) + TracePersistence.listFailure = undefined + TracePersistence.loadFailure = new Error('load unavailable') + await expect(failedCtx.sessionQuery.traceEvent({ sessionId: durable.id, seq: 0 })) + .rejects.toThrow(expectCode('SESSION_QUERY_PERSISTENCE_FAILED')) + TracePersistence.loadFailure = undefined + TracePersistence.afterList = () => { + TracePersistence.entries.get(durable.id)!.meta.cwd = '/changed' + } + await expect(failedCtx.sessionQuery.traceEvent({ sessionId: durable.id, seq: 0 })) + .rejects.toThrow(expectCode('SESSION_QUERY_SOURCE_CONFLICT')) + }) + + it('checks target existence before surface or provenance analysis', async () => { + const bad = header('bad-target') + const malformed: SessionEvent[] = [appendEvent(0), { + type: 'assistant/message', + seq: 1, + time: 2, + data: { turn: 1, step: 1, content: [] }, + surfaceOp: { op: 'replace', start: 9, end: 9 }, + sourceEventSeqs: [], + }] + TracePersistence.reset([{ meta: bad, events: malformed }]) + const ctx = await queryContext() + await ctx.plugin(TracePersistence) + + await expect(ctx.sessionQuery.traceEvent({ sessionId: bad.id, seq: 9 })) + .rejects.toThrow(expectCode('SESSION_QUERY_EVENT_NOT_FOUND')) + await expect(ctx.sessionQuery.traceEvent({ sessionId: bad.id, seq: 0 })) + .rejects.toThrow(expectCode('SESSION_QUERY_INVALID_SURFACE')) + }) + + it.each([ + ['non-surface sources', [ + { type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }, sourceEventSeqs: [0] }, + ]], + ['invalid source array', [ + { ...appendEvent(0), sourceEventSeqs: 'invalid' }, + ]], + ['empty sources', [ + appendEvent(0, []), + ]], + ['duplicate sources', [ + appendEvent(0), + appendEvent(1, [0, 0]), + ]], + ['missing earlier source', [ + appendEvent(0), + appendEvent(1, [-1]), + ]], + ['future source', [ + appendEvent(0, [1]), + appendEvent(1), + ]], + ['replacement without sources', [ + appendEvent(0), + { ...appendEvent(1), surfaceOp: { op: 'replace', start: 0, end: 0 } }, + ]], + ['replacement missing a shadowed source', [ + { type: 'assistant/chunk', seq: 0, time: 1, data: { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'draft' } } }, + appendEvent(1), + { ...appendEvent(2, [0]), surfaceOp: { op: 'replace', start: 1, end: 1 } }, + ]], + ] as const)('rejects invalid whole-log provenance: %s', async (_name, rawEvents) => { + const durable = header('invalid-provenance') + const events = structuredClone(rawEvents) as unknown as SessionEvent[] + TracePersistence.reset([{ meta: durable, events }]) + const ctx = await queryContext() + await ctx.plugin(TracePersistence) + + await expect(ctx.sessionQuery.traceEvent({ sessionId: durable.id, seq: 0 })) + .rejects.toThrow(expectCode('SESSION_QUERY_INVALID_PROVENANCE')) + }) + + it('keeps listEvents tolerant of malformed provenance alone', async () => { + const durable = header('list-regression') + TracePersistence.reset([{ meta: durable, events: [appendEvent(0), appendEvent(1, [0, 0])] }]) + const ctx = await queryContext() + await ctx.plugin(TracePersistence) + + await expect(ctx.sessionQuery.listEvents(durable.id)).resolves.toMatchObject([ + { seq: 0, surface: 'current' }, + { seq: 1, surface: 'current' }, + ]) + }) +}) diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index a815bf6a13..0e25b16d64 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -115,9 +115,9 @@ const SERVICE_ROLES: ServiceRole[] = [ { key: 'sessionQuery', pkg: 'session-query', - title: 'Exact session-history reads', + title: 'Exact session-history reads and traces', mode: 'seam', - note: 'Resolves live and optional persisted logs into one logical corpus for exact reads.', + note: 'Resolves live and optional persisted logs into one logical corpus for exact reads and relationship traces.', }, { key: 'systemPrompt', diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index e8faa2499d..cf4f3d7402 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -40,9 +40,13 @@ { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionEventSurface", "source": "packages/session-query/session-query/src/types.ts" }, { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionRecord", "source": "packages/session-query/session-query/src/types.ts" }, { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionEventRecord", "source": "packages/session-query/session-query/src/types.ts" }, + { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionLineageNode", "source": "packages/session-query/session-query/src/types.ts" }, + { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionLineageTrace", "source": "packages/session-query/session-query/src/types.ts" }, { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionQueryErrorCode", "source": "packages/session-query/session-query/src/config.ts" }, { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionEventReadRequest", "source": "packages/session-query/session-query/src/types.ts" }, { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionEventWindow", "source": "packages/session-query/session-query/src/types.ts" }, + { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionEventTraceRequest", "source": "packages/session-query/session-query/src/types.ts" }, + { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionEventTrace", "source": "packages/session-query/session-query/src/types.ts" }, { "doc": "docs/core-data-structures/tools.md", "symbol": "ToolDefinition", "source": "packages/core/tools/src/index.ts" }, { "doc": "docs/core-data-structures/tools.md", "symbol": "SchemaProp", "source": "packages/core/tools/src/schema.ts" }, From 8e019f2a65644c43d73997a16a9e8ef672ad9277 Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Mon, 13 Jul 2026 13:53:03 +0800 Subject: [PATCH 02/12] fix(session-query): avoid deep lineage recursion (round 2) --- .../session-query/src/tracing.ts | 24 +++++++++++++++---- .../session-query/tests/tracing.spec.ts | 21 ++++++++++++++++ 2 files changed, 41 insertions(+), 4 deletions(-) diff --git a/packages/session-query/session-query/src/tracing.ts b/packages/session-query/session-query/src/tracing.ts index 8efa2922b6..d14822844d 100644 --- a/packages/session-query/session-query/src/tracing.ts +++ b/packages/session-query/session-query/src/tracing.ts @@ -248,10 +248,26 @@ function buildDescendants( childrenByParent: ReadonlyMap, sessionId: SessionId, ): SessionLineageNode[] { - return (childrenByParent.get(sessionId) ?? []).map(child => ({ - session: cloneRecord(child), - descendants: buildDescendants(childrenByParent, child.header.id), - })) + const descendants: SessionLineageNode[] = [] + const stack = [{ sessionId, descendants }] + while (stack.length > 0) { + // The length guard proves a frame exists. + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const frame = stack.pop()! + const nodes: SessionLineageNode[] = [] + for (const child of childrenByParent.get(frame.sessionId) ?? []) { + const node = { session: cloneRecord(child), descendants: [] } + nodes.push(node) + frame.descendants.push(node) + } + for (let index = nodes.length - 1; index >= 0; index -= 1) { + // The loop bounds prove this indexed node exists. + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const node = nodes[index]! + stack.push({ sessionId: node.session.header.id, descendants: node.descendants }) + } + } + return descendants } function compareSessionsAscending(a: SessionRecord, b: SessionRecord): number { diff --git a/packages/session-query/session-query/tests/tracing.spec.ts b/packages/session-query/session-query/tests/tracing.spec.ts index 3128cb243f..9f4752feee 100644 --- a/packages/session-query/session-query/tests/tracing.spec.ts +++ b/packages/session-query/session-query/tests/tracing.spec.ts @@ -195,6 +195,27 @@ describe('session lineage tracing', () => { await expect(ctx.sessionQuery.traceSession(durable.id)) .rejects.toThrow(expectCode('SESSION_QUERY_PERSISTENCE_FAILED')) }) + + it('constructs deeply nested descendants without consuming the JavaScript call stack', async () => { + const ctx = await queryContext() + const root = ctx.sessions.create(SessionId('deep-0'), { meta: { createdAt: 0 } }) + let parent = root + for (let depth = 1; depth < 3_000; depth += 1) { + parent = ctx.sessions.create(SessionId(`deep-${depth}`), { + meta: { createdAt: depth, parentSession: parent.id }, + }) + } + + const trace = await ctx.sessionQuery.traceSession(root.id) + expect(trace.complete).toBe(true) + let node = trace.descendants[0] + for (let depth = 1; depth < 3_000; depth += 1) { + if (node === undefined) throw new Error(`lineage ended before depth ${depth}`) + if (depth === 2_999) expect(node.session.header.id).toBe(SessionId('deep-2999')) + node = node.descendants[0] + } + expect(node).toBeUndefined() + }) }) describe('session event tracing', () => { From be0d44183d3c35dc6665833aeaaf89c0499baeb5 Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Mon, 13 Jul 2026 14:01:34 +0800 Subject: [PATCH 03/12] perf(session-query): keep provenance validation linear (round 3) --- packages/session-query/session-query/src/tracing.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/session-query/session-query/src/tracing.ts b/packages/session-query/session-query/src/tracing.ts index d14822844d..d0c7f200cb 100644 --- a/packages/session-query/session-query/src/tracing.ts +++ b/packages/session-query/session-query/src/tracing.ts @@ -206,9 +206,10 @@ function validateProvenance( } for (const [replacementSeq, removedSeqs] of replacedEventSeqs) { - // The fold reports only replacement events from the input log. + // Canonical logs guarantee events[i].seq === i, and the fold reports only + // replacement events from this input log. // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const replacement = events.find(event => event.seq === replacementSeq)! + const replacement = events[replacementSeq]! const sources = rawEventSources(replacement) if (!Array.isArray(sources)) { invalidProvenance(`replacement at seq ${replacementSeq} omits its shadowed surface sources`) From 7351f0799580494b161424f3f8afe5d631f13704 Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Mon, 13 Jul 2026 14:17:52 +0800 Subject: [PATCH 04/12] refactor(session-query): inline tracing failures --- .../session-query/session-query/src/index.ts | 2 + .../session-query/src/tracing.ts | 76 ++++++++++--------- 2 files changed, 43 insertions(+), 35 deletions(-) diff --git a/packages/session-query/session-query/src/index.ts b/packages/session-query/session-query/src/index.ts index c468f31696..e5659c554a 100644 --- a/packages/session-query/session-query/src/index.ts +++ b/packages/session-query/session-query/src/index.ts @@ -78,6 +78,7 @@ export class SessionQueryService extends Service { * Trace known ancestry and descendants from one corpus observation. * @param sessionId - logical session id to trace. * @returns a complete lineage or an explicit unresolved parent boundary. + * @throws when corpus resolution fails, the target is absent, or its known ancestry cycles. */ async traceSession(sessionId: SessionId): Promise { const records = await this._corpus.listSessions() @@ -88,6 +89,7 @@ export class SessionQueryService extends Service { * Trace one event's direct positional and provenance relationships. * @param request - target session id and event seq. * @returns direct links plus the target's positional replacement chain. + * @throws when source resolution fails, the target is absent, or surface/provenance validation fails. */ async traceEvent(request: SessionEventTraceRequest): Promise { const loaded = await this._corpus.load(request.sessionId) diff --git a/packages/session-query/session-query/src/tracing.ts b/packages/session-query/session-query/src/tracing.ts index d0c7f200cb..c7cfd89b9a 100644 --- a/packages/session-query/session-query/src/tracing.ts +++ b/packages/session-query/session-query/src/tracing.ts @@ -105,7 +105,12 @@ export function traceLineage( let unresolvedParentId: SessionId | undefined let parentId = target.header.parentSession while (parentId !== undefined) { - if (ancestrySeen.has(parentId)) lineageCycle(parentId) + if (ancestrySeen.has(parentId)) { + throw new SessionQueryError( + `session lineage contains a cycle at "${parentId}"`, + 'SESSION_QUERY_INVALID_LINEAGE', + ) + } ancestrySeen.add(parentId) const parent = byId.get(parentId) if (parent === undefined) { @@ -146,7 +151,17 @@ function analyzeEventLog( sessionId: SessionId, events: readonly SessionEvent[], ): EventLogAnalysis { - const folded = safeFold(events) + let folded: ReturnType + try { + folded = foldSurface(events) + } catch (error: unknown) { + throw new SessionQueryError( + /* v8 ignore next -- foldSurface throws Error instances */ + `invalid session surface: ${error instanceof Error ? error.message : 'unknown error'}`, + 'SESSION_QUERY_INVALID_SURFACE', + { cause: error }, + ) + } const current = new Set(folded.nodes.map(node => node.seq)) const shadowed = new Set() const replacedBy = new Map() @@ -182,15 +197,24 @@ function validateProvenance( const sources = rawEventSources(event) if (sources === undefined) continue if (!isSurfaceEligibleType(event.type)) { - invalidProvenance(`non-surface event at seq ${event.seq} carries sourceEventSeqs`) + throw new SessionQueryError( + `invalid session provenance: non-surface event at seq ${event.seq} carries sourceEventSeqs`, + 'SESSION_QUERY_INVALID_PROVENANCE', + ) } if (!Array.isArray(sources) || sources.length === 0) { - invalidProvenance(`event at seq ${event.seq} has an empty or invalid sourceEventSeqs`) + throw new SessionQueryError( + `invalid session provenance: event at seq ${event.seq} has an empty or invalid sourceEventSeqs`, + 'SESSION_QUERY_INVALID_PROVENANCE', + ) } const unique = new Set() for (const source of sources as unknown[]) { if (unique.has(source)) { - invalidProvenance(`event at seq ${event.seq} repeats source seq ${String(source)}`) + throw new SessionQueryError( + `invalid session provenance: event at seq ${event.seq} repeats source seq ${String(source)}`, + 'SESSION_QUERY_INVALID_PROVENANCE', + ) } unique.add(source) if ( @@ -200,7 +224,10 @@ function validateProvenance( || source >= event.seq || events[source]?.seq !== source ) { - invalidProvenance(`event at seq ${event.seq} references unknown or non-earlier source seq ${String(source)}`) + throw new SessionQueryError( + `invalid session provenance: event at seq ${event.seq} references unknown or non-earlier source seq ${String(source)}`, + 'SESSION_QUERY_INVALID_PROVENANCE', + ) } } } @@ -212,12 +239,18 @@ function validateProvenance( const replacement = events[replacementSeq]! const sources = rawEventSources(replacement) if (!Array.isArray(sources)) { - invalidProvenance(`replacement at seq ${replacementSeq} omits its shadowed surface sources`) + throw new SessionQueryError( + `invalid session provenance: replacement at seq ${replacementSeq} omits its shadowed surface sources`, + 'SESSION_QUERY_INVALID_PROVENANCE', + ) } const sourceSet = new Set(sources as unknown[]) for (const removedSeq of removedSeqs) { if (!sourceSet.has(removedSeq)) { - invalidProvenance(`replacement at seq ${replacementSeq} omits shadowed surface seq ${removedSeq}`) + throw new SessionQueryError( + `invalid session provenance: replacement at seq ${replacementSeq} omits shadowed surface seq ${removedSeq}`, + 'SESSION_QUERY_INVALID_PROVENANCE', + ) } } } @@ -232,19 +265,6 @@ function eventSources(event: SessionEvent): number[] { return Array.isArray(sources) ? sources as number[] : [] } -function safeFold(events: readonly SessionEvent[]): ReturnType { - try { - return foldSurface(events) - } catch (error: unknown) { - throw new SessionQueryError( - /* v8 ignore next -- foldSurface throws Error instances */ - `invalid session surface: ${error instanceof Error ? error.message : 'unknown error'}`, - 'SESSION_QUERY_INVALID_SURFACE', - { cause: error }, - ) - } -} - function buildDescendants( childrenByParent: ReadonlyMap, sessionId: SessionId, @@ -278,17 +298,3 @@ function compareSessionsAscending(a: SessionRecord, b: SessionRecord): number { function cloneRecord(record: SessionRecord): SessionRecord { return { ...record, header: structuredClone(record.header) } } - -function lineageCycle(id: SessionId): never { - throw new SessionQueryError( - `session lineage contains a cycle at "${id}"`, - 'SESSION_QUERY_INVALID_LINEAGE', - ) -} - -function invalidProvenance(message: string): never { - throw new SessionQueryError( - `invalid session provenance: ${message}`, - 'SESSION_QUERY_INVALID_PROVENANCE', - ) -} From 75de01f06da916ca155bf481414b705680c20435 Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Mon, 13 Jul 2026 14:46:43 +0800 Subject: [PATCH 05/12] refactor(session): centralize surface provenance validation --- docs/config-catalog.md | 2 +- .../2026-07-13-session-query-tracing.md | 2 +- packages/core/session/README.md | 1 + packages/core/session/src/index.ts | 2 +- packages/core/session/src/surface.ts | 45 ++++++++ packages/core/session/tests/surface.spec.ts | 62 ++++++++++- .../session-query/session-query/README.md | 2 +- .../session-query/src/tracing.ts | 85 +++------------ packages/support/invariants/README.md | 1 + packages/support/invariants/src/index.ts | 100 ++++++++---------- 10 files changed, 171 insertions(+), 131 deletions(-) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 608046d053..055a66b926 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -359,7 +359,7 @@ export interface Config { } ``` -Source: [`packages/support/invariants/src/index.ts:45`](../packages/support/invariants/src/index.ts) +Source: [`packages/support/invariants/src/index.ts:52`](../packages/support/invariants/src/index.ts) ## `@deepseek-ai/dsh-llm-deepseek` diff --git a/docs/rfc/implemented/feature/2026-07-13-session-query-tracing.md b/docs/rfc/implemented/feature/2026-07-13-session-query-tracing.md index cb09531fc6..fa2f7a7e33 100644 --- a/docs/rfc/implemented/feature/2026-07-13-session-query-tracing.md +++ b/docs/rfc/implemented/feature/2026-07-13-session-query-tracing.md @@ -16,7 +16,7 @@ Session relationships are encoded across immutable headers, positional surface o ## Validation boundary -Event tracing checks target existence before surface analysis. Before returning a trace it validates the whole loaded log: only surface event types carry provenance, present arrays are nonempty and duplicate-free, every source is a known earlier seq, and every positional replacement names all surface nodes it removed. Provenance failures use `SESSION_QUERY_INVALID_PROVENANCE`; positional fold failures keep `SESSION_QUERY_INVALID_SURFACE`. `listEvents()` remains a surface-classification operation and does not acquire trace-specific provenance rejection. +Event tracing checks target existence before surface analysis. Before returning a trace it validates the whole loaded log through `dsh-session`'s shared provenance checker: only surface event types carry provenance, present arrays are nonempty and duplicate-free, every source is a known earlier seq, and every positional replacement names all surface nodes it removed. Provenance failures use `SESSION_QUERY_INVALID_PROVENANCE`; positional fold failures keep `SESSION_QUERY_INVALID_SURFACE`. `listEvents()` remains a surface-classification operation and does not acquire trace-specific provenance rejection. All returned records and arrays are detached. A known live event trace never consults persistence; persisted event traces preserve the exact-read list/load consistency check. Session lineage is necessarily a cross-corpus operation and therefore preserves cross-corpus persistence failure semantics. diff --git a/packages/core/session/README.md b/packages/core/session/README.md index 99be514fe9..db3952b1a3 100644 --- a/packages/core/session/README.md +++ b/packages/core/session/README.md @@ -48,6 +48,7 @@ Plain class (not a Cordis Service). Create via `ctx.sessions.create()`. - `SurfaceIntent` — `{ surfaceOp: SurfaceOp; sourceEventSeqs?: number[] }`, the required third parameter to `session.append()` for surface-eligible types. - `SurfaceNode` — `{ seq: number; prev: number | null; next: number | null }`, one node in the surface linked list. - `foldSurface(events)` — replay the canonical surface transitions into detached current nodes and actual replacement ranges, rejecting surface-eligible events that lack their mandatory marker. `SurfaceManager` shares the same transitions while retaining its incremental cache. +- `validateSurfaceProvenance(event, knownSeqs, shadowedSeqs?)` — pure provenance-contract check shared by incremental invariant listeners and exact readers. It returns the first violation instead of choosing a caller's error taxonomy. - `isSurfaceEvent(event)` / `isSurfaceEligibleType(type)` — the first narrows a `SessionEvent` to a fully-formed surface node (type is surface-eligible AND `surfaceOp` present); the second is the type-only check (is this one of the five `SurfaceEventType` values?), used to detect a surface-eligible event MISSING its marker — e.g. when validating a seed/load log. ### Request-header reconstruction (`request-header.ts`) diff --git a/packages/core/session/src/index.ts b/packages/core/session/src/index.ts index ebbf28d63e..2357649eb6 100644 --- a/packages/core/session/src/index.ts +++ b/packages/core/session/src/index.ts @@ -21,7 +21,7 @@ export { isJsonValue } from './json.ts' export type { JsonValue } from './json.ts' export { interruptedTurnClosers } from './repair.ts' export type { SurfaceFoldReplacement, SurfaceFoldResult, SurfaceNode } from './surface.ts' -export { foldSurface, isSurfaceEvent, isSurfaceEligibleType } from './surface.ts' +export { foldSurface, isSurfaceEvent, isSurfaceEligibleType, validateSurfaceProvenance } from './surface.ts' export { isToolPairingBalanced } from './tool-pairing.ts' export { applyHeaderDelta, canonicalHeader, diffHeader, foldRequestHeader, headerEquals } from './request-header.ts' diff --git a/packages/core/session/src/surface.ts b/packages/core/session/src/surface.ts index 3e4ce6d89c..8124f05fe6 100644 --- a/packages/core/session/src/surface.ts +++ b/packages/core/session/src/surface.ts @@ -81,6 +81,51 @@ export interface SurfaceFoldResult { replacements: SurfaceFoldReplacement[] } +/** + * Validate one event's logged provenance against the preceding log and the + * surface nodes it actually shadows. + * @param event - event whose optional `sourceEventSeqs` is being checked. + * @param knownSeqs - seqs preceding `event` in the same log. + * @param shadowedSeqs - surface nodes directly removed by this event. + * @returns the first contract violation, or `undefined` when provenance is valid. + */ +export function validateSurfaceProvenance( + event: SessionEvent, + knownSeqs: ReadonlySet, + shadowedSeqs: readonly number[] = [], +): string | undefined { + const sources = (event as SessionEvent & { sourceEventSeqs?: unknown }).sourceEventSeqs + if (sources !== undefined && !isSurfaceEligibleType(event.type)) { + return `${event.type} cannot carry sourceEventSeqs (non-surface event)` + } + if (sources !== undefined && !Array.isArray(sources)) { + return `sourceEventSeqs on event at seq ${event.seq} must be an array when present` + } + if (Array.isArray(sources) && sources.length === 0) { + return 'sourceEventSeqs must not be empty when present' + } + + const unique = new Set() + for (const source of sources ?? []) { + if (unique.has(source)) return 'sourceEventSeqs must not contain duplicates' + unique.add(source) + if (typeof source !== 'number' || !Number.isInteger(source) || source < 0) { + return `sourceEventSeqs contains invalid seq ${String(source)}` + } + if (source >= event.seq) { + return `sourceEventSeqs must reference earlier events: ${source} >= current seq ${event.seq}` + } + if (!knownSeqs.has(source)) return `sourceEventSeqs references unknown seq ${source}` + } + + const sourceSet = new Set(sources ?? []) + const missing = shadowedSeqs.filter(seq => !sourceSet.has(seq)) + if (missing.length > 0) { + return `surface replace: sourceEventSeqs must include every shadowed surface node; missing ${missing.join(', ')}` + } + return undefined +} + /** Mutable state shared by the incremental manager and the full-log fold. */ interface SurfaceFoldState { nodes: SurfaceNode[] diff --git a/packages/core/session/tests/surface.spec.ts b/packages/core/session/tests/surface.spec.ts index 1260b450a4..fe471c29ad 100644 --- a/packages/core/session/tests/surface.spec.ts +++ b/packages/core/session/tests/surface.spec.ts @@ -1,6 +1,13 @@ import { describe, expect, it } from 'vitest' import type { SessionEvent, SurfaceEvent, SurfaceEventType } from '@deepseek-ai/dsh-session' -import { Session, SessionId, foldSurface, isSurfaceEligibleType, isSurfaceEvent } from '@deepseek-ai/dsh-session' +import { + Session, + SessionId, + foldSurface, + isSurfaceEligibleType, + isSurfaceEvent, + validateSurfaceProvenance, +} from '@deepseek-ai/dsh-session' import { CallId } from '@deepseek-ai/dsh-llm' /** Build a minimal session with turn boundaries and a single user message. */ @@ -13,6 +20,59 @@ function surfaceSession(): Session { return s } +function provenanceEvent(seq: number, sourceEventSeqs: unknown): SessionEvent { + return { + type: 'user/message', + seq, + time: seq, + data: { content: [], source: { kind: 'user' } }, + surfaceOp: 'append', + sourceEventSeqs, + } as unknown as SessionEvent +} + +describe('validateSurfaceProvenance', () => { + it('accepts absent or valid provenance and complete replacement coverage', () => { + expect(validateSurfaceProvenance(provenanceEvent(0, undefined), new Set())) + .toBeUndefined() + expect(validateSurfaceProvenance(provenanceEvent(2, [0, 1]), new Set([0, 1]), [1])) + .toBeUndefined() + }) + + it('rejects provenance on a non-surface event', () => { + const event = { + type: 'turn/start', + seq: 1, + time: 1, + data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }, + sourceEventSeqs: [0], + } as unknown as SessionEvent + expect(validateSurfaceProvenance(event, new Set([0]))) + .toMatch(/cannot carry sourceEventSeqs/) + }) + + it.each([ + ['a non-array', 1, 'invalid', new Set([0]), [], /must be an array/], + ['an empty array', 1, [], new Set([0]), [], /must not be empty/], + ['duplicates', 1, [0, 0], new Set([0]), [], /must not contain duplicates/], + ['a non-number', 1, ['0'], new Set([0]), [], /invalid seq 0/], + ['a fractional number', 1, [0.5], new Set([0]), [], /invalid seq 0\.5/], + ['a negative number', 1, [-1], new Set([0]), [], /invalid seq -1/], + ['a self reference', 1, [1], new Set([0]), [], /must reference earlier events/], + ['an unknown earlier seq', 2, [1], new Set([0]), [], /references unknown seq 1/], + ['incomplete replacement coverage', 2, [0], new Set([0, 1]), [0, 1], /missing 1/], + ] as const)( + 'returns the first violation for %s', + (_name, seq, sources, knownSeqs, shadowedSeqs, expected) => { + expect(validateSurfaceProvenance( + provenanceEvent(seq, sources), + knownSeqs, + shadowedSeqs, + )).toMatch(expected) + }, + ) +}) + describe('SurfaceManager', () => { it('shares exact nodes and nested replacement ranges with foldSurface', () => { const s = new Session(SessionId('shared-fold')) diff --git a/packages/session-query/session-query/README.md b/packages/session-query/session-query/README.md index f85ade5984..003fc9a839 100644 --- a/packages/session-query/session-query/README.md +++ b/packages/session-query/session-query/README.md @@ -14,7 +14,7 @@ This is trusted context-wide infrastructure. It performs no caller authorization Persistence is optional and may mount or unmount dynamically. Cross-corpus listing and lineage tracing fail with `SESSION_QUERY_PERSISTENCE_FAILED` while mounted persistence is unreadable. An event read or trace targeting a known live session does not consult persistence, so durable backend health cannot make current in-memory history unreadable. Persisted event operations list before loading and reject a metadata mismatch rather than combining inconsistent observations. -`traceEvent()` validates the whole loaded log before returning relationships: provenance arrays are nonempty and duplicate-free, references name known earlier events, only surface event types carry sources, and each positional replacement names every surface node it removed. Provenance violations fail with `SESSION_QUERY_INVALID_PROVENANCE`; positional fold failures remain `SESSION_QUERY_INVALID_SURFACE`. `listEvents()` only needs surface classification and deliberately does not enforce the trace-specific provenance contract. +`traceEvent()` validates the whole loaded log with `dsh-session`'s shared provenance checker before returning relationships: provenance arrays are nonempty and duplicate-free, references name known earlier events, only surface event types carry sources, and each positional replacement names every surface node it removed. Provenance violations fail with `SESSION_QUERY_INVALID_PROVENANCE`; positional fold failures remain `SESSION_QUERY_INVALID_SURFACE`. `listEvents()` only needs surface classification and deliberately does not enforce the trace-specific provenance contract. `SessionQueryError.code` is a closed union: `SESSION_QUERY_EVENT_NOT_FOUND`, `SESSION_QUERY_INVALID_CONFIG`, `SESSION_QUERY_INVALID_LINEAGE`, `SESSION_QUERY_INVALID_PROVENANCE`, `SESSION_QUERY_INVALID_SURFACE`, `SESSION_QUERY_INVALID_WINDOW`, `SESSION_QUERY_PERSISTENCE_FAILED`, `SESSION_QUERY_SESSION_NOT_FOUND`, and `SESSION_QUERY_SOURCE_CONFLICT`. diff --git a/packages/session-query/session-query/src/tracing.ts b/packages/session-query/session-query/src/tracing.ts index c7cfd89b9a..8327877ee3 100644 --- a/packages/session-query/session-query/src/tracing.ts +++ b/packages/session-query/session-query/src/tracing.ts @@ -1,6 +1,6 @@ /** One-shot session-lineage and event-relationship tracing helpers. */ -import { foldSurface, isSurfaceEligibleType } from '@deepseek-ai/dsh-session' +import { foldSurface, validateSurfaceProvenance } from '@deepseek-ai/dsh-session' import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session' import { SessionQueryError } from './config.ts' import type { @@ -51,7 +51,21 @@ export function traceEventLog( } const analysis = analyzeEventLog(sessionId, events) - validateProvenance(events, analysis.replacedEventSeqs) + const knownSeqs = new Set() + for (const event of events) { + const violation = validateSurfaceProvenance( + event, + knownSeqs, + analysis.replacedEventSeqs.get(event.seq), + ) + if (violation !== undefined) { + throw new SessionQueryError( + `invalid session provenance: ${violation}`, + 'SESSION_QUERY_INVALID_PROVENANCE', + ) + } + knownSeqs.add(event.seq) + } const replacementChain: number[] = [] let replacement = analysis.replacedBy.get(seq) @@ -189,73 +203,6 @@ function analyzeEventLog( } } -function validateProvenance( - events: readonly SessionEvent[], - replacedEventSeqs: ReadonlyMap, -): void { - for (const event of events) { - const sources = rawEventSources(event) - if (sources === undefined) continue - if (!isSurfaceEligibleType(event.type)) { - throw new SessionQueryError( - `invalid session provenance: non-surface event at seq ${event.seq} carries sourceEventSeqs`, - 'SESSION_QUERY_INVALID_PROVENANCE', - ) - } - if (!Array.isArray(sources) || sources.length === 0) { - throw new SessionQueryError( - `invalid session provenance: event at seq ${event.seq} has an empty or invalid sourceEventSeqs`, - 'SESSION_QUERY_INVALID_PROVENANCE', - ) - } - const unique = new Set() - for (const source of sources as unknown[]) { - if (unique.has(source)) { - throw new SessionQueryError( - `invalid session provenance: event at seq ${event.seq} repeats source seq ${String(source)}`, - 'SESSION_QUERY_INVALID_PROVENANCE', - ) - } - unique.add(source) - if ( - typeof source !== 'number' - || !Number.isInteger(source) - || source < 0 - || source >= event.seq - || events[source]?.seq !== source - ) { - throw new SessionQueryError( - `invalid session provenance: event at seq ${event.seq} references unknown or non-earlier source seq ${String(source)}`, - 'SESSION_QUERY_INVALID_PROVENANCE', - ) - } - } - } - - for (const [replacementSeq, removedSeqs] of replacedEventSeqs) { - // Canonical logs guarantee events[i].seq === i, and the fold reports only - // replacement events from this input log. - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const replacement = events[replacementSeq]! - const sources = rawEventSources(replacement) - if (!Array.isArray(sources)) { - throw new SessionQueryError( - `invalid session provenance: replacement at seq ${replacementSeq} omits its shadowed surface sources`, - 'SESSION_QUERY_INVALID_PROVENANCE', - ) - } - const sourceSet = new Set(sources as unknown[]) - for (const removedSeq of removedSeqs) { - if (!sourceSet.has(removedSeq)) { - throw new SessionQueryError( - `invalid session provenance: replacement at seq ${replacementSeq} omits shadowed surface seq ${removedSeq}`, - 'SESSION_QUERY_INVALID_PROVENANCE', - ) - } - } - } -} - function rawEventSources(event: SessionEvent): unknown { return (event as SessionEvent & { sourceEventSeqs?: unknown }).sourceEventSeqs } diff --git a/packages/support/invariants/README.md b/packages/support/invariants/README.md index 94c2682f9d..95ea6261ee 100644 --- a/packages/support/invariants/README.md +++ b/packages/support/invariants/README.md @@ -31,6 +31,7 @@ await ctx.plugin(Invariants, { freeze: false }) // assert contract, don't freez Session log (per session): - **`seq` strictly increases** — the spine of replay equivalence. +- **surface provenance is valid** — `sourceEventSeqs` uses the shared `dsh-session` checker for type eligibility, nonempty unique earlier references, and complete replacement coverage. - **turns pair and nest** — `turn/start` opens a turn, `turn/end` closes the matching one; no overlapping turns. - **steps nest in turns** — `step/start` opens a step in the open turn; `step/end` closes the matching step. - **chunks belong to an open step** — `step/start` precedes its `assistant/chunk`s. diff --git a/packages/support/invariants/src/index.ts b/packages/support/invariants/src/index.ts index 8147a6deb6..fb56519906 100644 --- a/packages/support/invariants/src/index.ts +++ b/packages/support/invariants/src/index.ts @@ -7,7 +7,8 @@ * `session/event`, and `agent/status`. It is **off in production**: enable it * in tests and the demos, where a contract violation should be a loud failure, * not a subtle one. It doubles as executable documentation of the event - * taxonomy: the assertions below ARE the contract. + * taxonomy: these assertions and the shared session validators they invoke + * are the contract. * * Why runtime assertions instead of compile-time deep-readonly types? See * the dev-invariants RFC. Briefly: a `DeepReadonly` is high type-noise across @@ -23,7 +24,13 @@ import type { Context } from 'cordis' import { HarnessError } from '@deepseek-ai/dsh-llm' import type { CallId, GenerateOptions } from '@deepseek-ai/dsh-llm' import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent' -import { Session, SessionId, foldRequestHeader } from '@deepseek-ai/dsh-session' +import { + Session, + SessionId, + foldRequestHeader, + isSurfaceEligibleType, + validateSurfaceProvenance, +} from '@deepseek-ai/dsh-session' import type { SessionEvent, SurfaceEventType } from '@deepseek-ai/dsh-session' export const name = 'invariants' @@ -121,71 +128,50 @@ function checkEvent(trace: SessionTrace, event: SessionEvent): void { trace.lastSeq = event.seq // --- Surface invariants --- - // Surface metadata (sourceEventSeqs, surfaceOp) is only valid on - // surface-eligible event types. The compiler enforces this at append() - // call sites; this runtime check catches casts and persisted data. - const SURFACE_TYPES = new Set(['user/message', 'assistant/message', 'tool/result', 'context/message', 'steering/message']) // Cast to surface-eligible event type so we can access surfaceOp and // sourceEventSeqs (optional on SessionEvent, mandatory on SurfaceEvent). // SurfaceEvent's mandatory surfaceOp is too strict here — we need to // CHECK whether surface metadata is present, not assume it. const se = event as SessionEvent - if (!SURFACE_TYPES.has(event.type)) { - if (se.sourceEventSeqs !== undefined) { - throw new InvariantError(`${event.type} cannot carry sourceEventSeqs (non-surface event)`) - } - if (se.surfaceOp !== undefined) { - throw new InvariantError(`${event.type} cannot carry surfaceOp (non-surface event)`) - } - } - if (se.sourceEventSeqs !== undefined) { - if (se.sourceEventSeqs.length === 0) { - throw new InvariantError('sourceEventSeqs must not be empty when present') - } - const unique = new Set(se.sourceEventSeqs) - if (unique.size !== se.sourceEventSeqs.length) { - throw new InvariantError('sourceEventSeqs must not contain duplicates') - } - for (const ref of se.sourceEventSeqs) { - if (ref >= event.seq) { - throw new InvariantError(`sourceEventSeqs must reference earlier events: ${ref} >= current seq ${event.seq}`) - } - if (!trace.knownSeqs.has(ref)) { - throw new InvariantError(`sourceEventSeqs references unknown seq ${ref}`) - } - } + if (!isSurfaceEligibleType(event.type) && se.surfaceOp !== undefined) { + throw new InvariantError(`${event.type} cannot carry surfaceOp (non-surface event)`) } + // Fold this event into the tracked surface linked list, validating the // replace contract as we go. `append` adds a tail node; `replace` shadows a // positional range — every shadowed node must appear in sourceEventSeqs. - if (se.surfaceOp !== undefined) { - if (se.surfaceOp === 'append') { - trace.surface.push(event.seq) - } else { - const { start, end } = se.surfaceOp - const startIdx = trace.surface.indexOf(start) - if (startIdx === -1) { - throw new InvariantError(`surface replace: start seq ${start} is not on the surface`) - } - const endIdx = trace.surface.indexOf(end) - if (endIdx === -1) { - throw new InvariantError(`surface replace: end seq ${end} is not on the surface`) - } - if (startIdx > endIdx) { - throw new InvariantError(`surface replace: start seq ${start} (pos ${startIdx}) is after end seq ${end} (pos ${endIdx}) on the surface`) - } - // Every node the replace shadows (surface positions [startIdx, endIdx] - // inclusive) must appear in sourceEventSeqs — the provenance contract. - const shadowed = trace.surface.slice(startIdx, endIdx + 1) - const recorded = new Set(se.sourceEventSeqs ?? []) - const missing = shadowed.filter(seq => !recorded.has(seq)) - if (missing.length > 0) { - throw new InvariantError(`surface replace: sourceEventSeqs must include every shadowed surface node; missing ${missing.join(', ')}`) - } - // Apply the replace to the tracked surface: the new node takes the - // range's position so order stays in sync for later replaces. - trace.surface.splice(startIdx, shadowed.length, event.seq) + let replacement: { startIdx: number; shadowed: number[] } | undefined + if (se.surfaceOp !== undefined && se.surfaceOp !== 'append') { + const { start, end } = se.surfaceOp + const startIdx = trace.surface.indexOf(start) + if (startIdx === -1) { + throw new InvariantError(`surface replace: start seq ${start} is not on the surface`) } + const endIdx = trace.surface.indexOf(end) + if (endIdx === -1) { + throw new InvariantError(`surface replace: end seq ${end} is not on the surface`) + } + if (startIdx > endIdx) { + throw new InvariantError(`surface replace: start seq ${start} (pos ${startIdx}) is after end seq ${end} (pos ${endIdx}) on the surface`) + } + replacement = { startIdx, shadowed: trace.surface.slice(startIdx, endIdx + 1) } + } + + const provenanceViolation = validateSurfaceProvenance( + event, + trace.knownSeqs, + replacement?.shadowed, + ) + if (provenanceViolation !== undefined) { + throw new InvariantError(provenanceViolation) + } + + if (se.surfaceOp === 'append') { + trace.surface.push(event.seq) + } else if (replacement !== undefined) { + // The new node takes the replaced range's position so order stays in sync + // for later replacements. + trace.surface.splice(replacement.startIdx, replacement.shadowed.length, event.seq) } // Boundary/step-scoped events have explicit cases; every OTHER event type — From 84e6f72ef57083cb968fa3473a75bc1caa3b1f79 Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Mon, 13 Jul 2026 16:05:11 +0800 Subject: [PATCH 06/12] fix(session-query): reject misplaced surface ops --- docs/cordis-catalog/services.md | 2 +- .../2026-07-13-session-query-tracing.md | 2 +- packages/core/session/README.md | 4 +- packages/core/session/src/index.ts | 57 ++----- packages/core/session/src/surface.ts | 139 +++++++++++++----- packages/core/session/tests/session.spec.ts | 11 +- packages/core/session/tests/surface.spec.ts | 42 ++++-- .../session-query/session-query/README.md | 2 +- .../session-query/src/tracing.ts | 6 +- .../session-query/tests/tracing.spec.ts | 17 +++ packages/support/invariants/README.md | 2 +- packages/support/invariants/src/index.ts | 12 +- 12 files changed, 182 insertions(+), 114 deletions(-) diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 20420a96b7..e3f2f8ffd0 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -232,7 +232,7 @@ list(): Session[] fork(source: SessionForkSource, boundary?: number, childSessionId?: SessionId): Session ``` -Source: [`packages/core/session/src/index.ts:590`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:557`](../../packages/core/session/src/index.ts) ## `ctx.skills` — `SkillService` diff --git a/docs/rfc/implemented/feature/2026-07-13-session-query-tracing.md b/docs/rfc/implemented/feature/2026-07-13-session-query-tracing.md index fa2f7a7e33..ff2e9b7c23 100644 --- a/docs/rfc/implemented/feature/2026-07-13-session-query-tracing.md +++ b/docs/rfc/implemented/feature/2026-07-13-session-query-tracing.md @@ -16,7 +16,7 @@ Session relationships are encoded across immutable headers, positional surface o ## Validation boundary -Event tracing checks target existence before surface analysis. Before returning a trace it validates the whole loaded log through `dsh-session`'s shared provenance checker: only surface event types carry provenance, present arrays are nonempty and duplicate-free, every source is a known earlier seq, and every positional replacement names all surface nodes it removed. Provenance failures use `SESSION_QUERY_INVALID_PROVENANCE`; positional fold failures keep `SESSION_QUERY_INVALID_SURFACE`. `listEvents()` remains a surface-classification operation and does not acquire trace-specific provenance rejection. +Event tracing checks target existence before surface analysis. Before returning a trace it validates the whole loaded log through `dsh-session`'s shared surface-metadata checker: surface markers obey event-type eligibility, provenance belongs only to surface event types, present arrays are nonempty and duplicate-free, every source is a known earlier seq, and every positional replacement names all surface nodes it removed. Surface-marker and positional-fold failures use `SESSION_QUERY_INVALID_SURFACE`; provenance failures use `SESSION_QUERY_INVALID_PROVENANCE`. `listEvents()` remains a surface-classification operation and does not acquire trace-specific provenance rejection. All returned records and arrays are detached. A known live event trace never consults persistence; persisted event traces preserve the exact-read list/load consistency check. Session lineage is necessarily a cross-corpus operation and therefore preserves cross-corpus persistence failure semantics. diff --git a/packages/core/session/README.md b/packages/core/session/README.md index c575777ccd..fc7e6d4300 100644 --- a/packages/core/session/README.md +++ b/packages/core/session/README.md @@ -49,8 +49,8 @@ Durable values need one accepted representation, not a check followed by a secon - `SurfaceOp` — how a surface node entered the linked list: `'append'` (normal tail append) or `{ op: 'replace', start, end }` (replace nodes from `start` through `end` inclusive — both must be valid surface node seqs; `start === end` replaces a single node). Used by compaction to shadow old nodes without deleting them. - `SurfaceIntent` — `{ surfaceOp: SurfaceOp; sourceEventSeqs?: number[] }`, the required third parameter to `session.append()` for surface-eligible types. - `SurfaceNode` — `{ seq: number; prev: number | null; next: number | null }`, one node in the surface linked list. -- `foldSurface(events)` — replay the canonical surface transitions into detached current nodes and actual replacement ranges, rejecting surface-eligible events that lack their mandatory marker. `SurfaceManager` shares the same transitions while retaining its incremental cache. -- `validateSurfaceProvenance(event, knownSeqs, shadowedSeqs?)` — pure provenance-contract check shared by incremental invariant listeners and exact readers. It returns the first violation instead of choosing a caller's error taxonomy. +- `foldSurface(events)` — replay the canonical surface transitions into detached current nodes and actual replacement ranges, rejecting misplaced, missing, malformed, or positionally invalid `surfaceOp` metadata while leaving provenance validation to callers. `SurfaceManager` shares the same transitions while retaining its incremental cache. +- `validateSurfaceMetadata(event, knownSeqs?, shadowedSeqs?)` — canonical structural and provenance check shared by session acceptance, surface folding, incremental invariants, and exact readers. It tags violations as `surface` or `provenance` so callers retain their error taxonomy; omit `knownSeqs` for local shape validation only. - `isSurfaceEvent(event)` / `isSurfaceEligibleType(type)` — the first narrows a `SessionEvent` to a fully-formed surface node (type is surface-eligible AND `surfaceOp` present); the second is the type-only check (is this one of the five `SurfaceEventType` values?), used to detect a surface-eligible event MISSING its marker — e.g. when validating a seed/load log. ### Request-header reconstruction (`request-header.ts`) diff --git a/packages/core/session/src/index.ts b/packages/core/session/src/index.ts index 87a5cff8a6..88b3c15aff 100644 --- a/packages/core/session/src/index.ts +++ b/packages/core/session/src/index.ts @@ -15,7 +15,7 @@ import type { ContentBlock, Message, MessageSource } from '@deepseek-ai/dsh-llm' import { SESSION_FORMAT_VERSION, SessionId } from './types.ts' import type { CreateSessionOptions, EpochHeader, SessionEvent, SessionEventMap, SessionEventType, SessionHeader, SurfaceIntent, SurfaceEventType } from './types.ts' import { snapshotJsonValue } from './json.ts' -import { SurfaceManager, isSurfaceEligibleType } from './surface.ts' +import { SurfaceManager, validateSurfaceMetadata } from './surface.ts' import { foldRequestHeader } from './request-header.ts' export * from './types.ts' @@ -23,7 +23,7 @@ export { isJsonValue, snapshotJsonValue } from './json.ts' export type { JsonValue } from './json.ts' export { interruptedTurnClosers } from './repair.ts' export type { SurfaceFoldReplacement, SurfaceFoldResult, SurfaceNode } from './surface.ts' -export { foldSurface, isSurfaceEvent, isSurfaceEligibleType, validateSurfaceProvenance } from './surface.ts' +export { foldSurface, isSurfaceEvent, isSurfaceEligibleType, validateSurfaceMetadata } from './surface.ts' export { isToolPairingBalanced } from './tool-pairing.ts' export { applyHeaderDelta, canonicalHeader, diffHeader, foldRequestHeader, headerEquals } from './request-header.ts' @@ -157,43 +157,6 @@ function snapshotSessionHeader(id: SessionId, source?: SessionHeader): SessionHe return deepFreeze(record as unknown as SessionHeader) } -/** Validate the runtime shape of surface metadata after its JSON snapshot. */ -function assertSurfaceMetadataShape( - type: string, - surfaceOp: unknown, - sourceEventSeqs: unknown, -): void { - const eligible = isSurfaceEligibleType(type) - if (!eligible) { - if (surfaceOp !== undefined || sourceEventSeqs !== undefined) { - throw new Error(`session event "${type}" is not surface-eligible and cannot carry surface metadata`) - } - return - } - if (surfaceOp === undefined) { - throw new Error(`session event "${type}" is surface-eligible and requires a surfaceOp marker`) - } - if (surfaceOp !== 'append') { - if (surfaceOp === null || typeof surfaceOp !== 'object' || Array.isArray(surfaceOp)) { - throw new Error(`session event "${type}" carries an invalid surfaceOp`) - } - const op = surfaceOp as Record - const keys = Object.keys(op) - if (keys.length !== 3 || !Object.hasOwn(op, 'op') || !Object.hasOwn(op, 'start') || !Object.hasOwn(op, 'end') - || op['op'] !== 'replace' - || typeof op['start'] !== 'number' || !Number.isSafeInteger(op['start']) || op['start'] < 0 - || typeof op['end'] !== 'number' || !Number.isSafeInteger(op['end']) || op['end'] < 0) { - throw new Error(`session event "${type}" carries an invalid replace surfaceOp`) - } - } - if (sourceEventSeqs !== undefined) { - if (!Array.isArray(sourceEventSeqs) - || sourceEventSeqs.some(seq => typeof seq !== 'number' || !Number.isSafeInteger(seq) || seq < 0)) { - throw new Error(`session event "${type}" sourceEventSeqs must contain non-negative safe integers`) - } - } -} - /** Validate the fixed event envelope after one-pass JSON materialization. */ function assertSessionEventEnvelope(value: Record, index: number): asserts value is SessionEvent { const event = value @@ -312,12 +275,15 @@ export class Session { // this at compile time via its typed overload; a seed arrives as raw // SessionEvent[] (replay/fork/load), bypassing that, so re-check at // runtime here rather than silently resuming with empty history. - const structural = snapshot as SessionEvent & { surfaceOp?: unknown; sourceEventSeqs?: unknown } + let violation: ReturnType try { - assertSurfaceMetadataShape(snapshot.type, structural.surfaceOp, structural.sourceEventSeqs) + violation = validateSurfaceMetadata(snapshot) } catch (error: unknown) { throw new Error(`invalid seed event at index ${index}: ${error instanceof Error ? error.message : 'invalid surface metadata'}`) } + if (violation !== undefined) { + throw new Error(`invalid seed event at index ${index}: ${violation.message}`) + } return deepFreeze(snapshot) }) } @@ -392,11 +358,12 @@ export class Session { if (surfaceMetadataSnapshot === undefined) { throw new Error(`session event "${type}" carries non-JSON-serializable surface metadata`) } - assertSurfaceMetadataShape( + const surfaceViolation = validateSurfaceMetadata({ type, - (surfaceMetadataSnapshot as { surfaceOp?: unknown }).surfaceOp, - (surfaceMetadataSnapshot as { sourceEventSeqs?: unknown }).sourceEventSeqs, - ) + seq: this.log.length, + ...(surfaceMetadataSnapshot as { surfaceOp?: unknown; sourceEventSeqs?: unknown }), + }) + if (surfaceViolation !== undefined) throw new Error(surfaceViolation.message) const entry = attachments.get(this) if (entry?.appending) { diff --git a/packages/core/session/src/surface.ts b/packages/core/session/src/surface.ts index 8124f05fe6..c8642d18fc 100644 --- a/packages/core/session/src/surface.ts +++ b/packages/core/session/src/surface.ts @@ -82,46 +82,110 @@ export interface SurfaceFoldResult { } /** - * Validate one event's logged provenance against the preceding log and the - * surface nodes it actually shadows. - * @param event - event whose optional `sourceEventSeqs` is being checked. - * @param knownSeqs - seqs preceding `event` in the same log. + * Validate one event's surface metadata through the canonical structural and + * provenance contract. Structural validation always runs; when `knownSeqs` is + * supplied, provenance must additionally name unique known earlier events and + * cover every shadowed surface node. The tagged result lets callers retain + * their own surface-versus-provenance error taxonomy. + * @param event - event whose `surfaceOp` and `sourceEventSeqs` are being checked. + * @param knownSeqs - seqs preceding `event`, or `undefined` for local shape validation only. * @param shadowedSeqs - surface nodes directly removed by this event. - * @returns the first contract violation, or `undefined` when provenance is valid. + * @returns the first tagged contract violation, or `undefined` when valid. */ -export function validateSurfaceProvenance( - event: SessionEvent, - knownSeqs: ReadonlySet, +export function validateSurfaceMetadata( + event: Pick & { + surfaceOp?: unknown + sourceEventSeqs?: unknown + }, + knownSeqs?: ReadonlySet, shadowedSeqs: readonly number[] = [], -): string | undefined { - const sources = (event as SessionEvent & { sourceEventSeqs?: unknown }).sourceEventSeqs - if (sources !== undefined && !isSurfaceEligibleType(event.type)) { - return `${event.type} cannot carry sourceEventSeqs (non-surface event)` +): { kind: 'surface' | 'provenance'; message: string } | undefined { + const eligible = isSurfaceEligibleType(event.type) + const surfaceOp = event.surfaceOp + const sources = event.sourceEventSeqs + + if (!eligible && surfaceOp !== undefined) { + return { + kind: 'surface', + message: `session event "${event.type}" is not surface-eligible and cannot carry surfaceOp`, + } + } + if (eligible && surfaceOp === undefined) { + return { + kind: 'surface', + message: `session event "${event.type}" is surface-eligible and requires a surfaceOp marker`, + } + } + if (surfaceOp !== undefined && surfaceOp !== 'append') { + if (surfaceOp === null || typeof surfaceOp !== 'object' || Array.isArray(surfaceOp)) { + return { + kind: 'surface', + message: `session event "${event.type}" carries an invalid surfaceOp`, + } + } + const op = surfaceOp as Record + const keys = Object.keys(op) + if (keys.length !== 3 || !Object.hasOwn(op, 'op') || !Object.hasOwn(op, 'start') || !Object.hasOwn(op, 'end') + || op['op'] !== 'replace' + || typeof op['start'] !== 'number' || !Number.isSafeInteger(op['start']) || op['start'] < 0 + || typeof op['end'] !== 'number' || !Number.isSafeInteger(op['end']) || op['end'] < 0) { + return { + kind: 'surface', + message: `session event "${event.type}" carries an invalid replace surfaceOp`, + } + } + } + + if (sources !== undefined && !eligible) { + return { + kind: 'provenance', + message: `${event.type} cannot carry sourceEventSeqs (non-surface event)`, + } } if (sources !== undefined && !Array.isArray(sources)) { - return `sourceEventSeqs on event at seq ${event.seq} must be an array when present` + return { + kind: 'provenance', + message: `sourceEventSeqs on event at seq ${event.seq} must be an array when present`, + } } - if (Array.isArray(sources) && sources.length === 0) { - return 'sourceEventSeqs must not be empty when present' + if (Array.isArray(sources) + && sources.some(source => typeof source !== 'number' || !Number.isSafeInteger(source) || source < 0)) { + return { + kind: 'provenance', + message: `session event "${event.type}" sourceEventSeqs must contain non-negative safe integers`, + } + } + if (knownSeqs === undefined) return + + const sourceSeqs = sources as number[] | undefined + if (sourceSeqs !== undefined && sourceSeqs.length === 0) { + return { kind: 'provenance', message: 'sourceEventSeqs must not be empty when present' } } - const unique = new Set() - for (const source of sources ?? []) { - if (unique.has(source)) return 'sourceEventSeqs must not contain duplicates' + const unique = new Set() + for (const source of sourceSeqs ?? []) { + if (unique.has(source)) { + return { kind: 'provenance', message: 'sourceEventSeqs must not contain duplicates' } + } unique.add(source) - if (typeof source !== 'number' || !Number.isInteger(source) || source < 0) { - return `sourceEventSeqs contains invalid seq ${String(source)}` - } if (source >= event.seq) { - return `sourceEventSeqs must reference earlier events: ${source} >= current seq ${event.seq}` + return { + kind: 'provenance', + message: `sourceEventSeqs must reference earlier events: ${source} >= current seq ${event.seq}`, + } + } + if (!knownSeqs.has(source)) { + return { kind: 'provenance', message: `sourceEventSeqs references unknown seq ${source}` } } - if (!knownSeqs.has(source)) return `sourceEventSeqs references unknown seq ${source}` } - const sourceSet = new Set(sources ?? []) + const sourceSet = new Set(sourceSeqs ?? []) const missing = shadowedSeqs.filter(seq => !sourceSet.has(seq)) if (missing.length > 0) { - return `surface replace: sourceEventSeqs must include every shadowed surface node; missing ${missing.join(', ')}` + return { + kind: 'provenance', + message: `surface replace: sourceEventSeqs must include every shadowed surface node; missing ${missing.join(', ')}`, + } } return undefined } @@ -147,25 +211,26 @@ function applySurfaceEvent( state: SurfaceFoldState, event: SessionEvent, ): SurfaceFoldReplacement | undefined { + const violation = validateSurfaceMetadata(event) + if (violation?.kind === 'surface') throw new Error(violation.message) if (!isSurfaceEligibleType(event.type)) return - if (!isSurfaceEvent(event)) { - throw new Error(`surface event "${event.type}" (seq ${event.seq}) carries no surfaceOp marker`) - } + // The canonical metadata validation above proves this runtime shape. + const surfaceEvent = event as SurfaceEvent - if (event.surfaceOp === 'append') { + if (surfaceEvent.surfaceOp === 'append') { const tail = state.nodes.length > 0 ? state.nodes[state.nodes.length - 1] : undefined - const node: SurfaceNode = { seq: event.seq, prev: tail?.seq ?? null, next: null } - if (tail) tail.next = event.seq + const node: SurfaceNode = { seq: surfaceEvent.seq, prev: tail?.seq ?? null, next: null } + if (tail) tail.next = surfaceEvent.seq state.nodes.push(node) - state.nodeBySeq.set(event.seq, node) + state.nodeBySeq.set(surfaceEvent.seq, node) return } return { - seq: event.seq, - start: event.surfaceOp.start, - end: event.surfaceOp.end, - shadowedSeqs: replaceSurface(state, event.seq, event.surfaceOp), + seq: surfaceEvent.seq, + start: surfaceEvent.surfaceOp.start, + end: surfaceEvent.surfaceOp.end, + shadowedSeqs: replaceSurface(state, surfaceEvent.seq, surfaceEvent.surfaceOp), } } @@ -215,7 +280,7 @@ function replaceSurface( * models cannot disagree with `deriveMessages()` about replacement ranges. * @param events - session events in contiguous seq order. * @returns the current surface and every positional replacement. - * @throws when a surface-eligible event lacks its mandatory `surfaceOp`, or a + * @throws when an event violates the `surfaceOp` type/marker contract, or a * replacement names nodes that are absent or reversed on the current surface. */ export function foldSurface(events: readonly SessionEvent[]): SurfaceFoldResult { diff --git a/packages/core/session/tests/session.spec.ts b/packages/core/session/tests/session.spec.ts index 2204fc9027..fe44de871f 100644 --- a/packages/core/session/tests/session.spec.ts +++ b/packages/core/session/tests/session.spec.ts @@ -313,10 +313,13 @@ describe('Session', () => { expect(event.surfaceOp).toEqual({ op: 'replace', start: 0, end: 0 }) }) - it('adds seed context when surface validation throws a non-Error value', () => { + it.each([ + ['an Error', new Error('validator failed'), 'validator failed'], + ['a non-Error value', 'validator failed', 'invalid surface metadata'], + ] as const)('adds seed context when surface validation throws %s', (_name, failure, expected) => { const originalHasOwn = Object.hasOwn const hasOwn = vi.spyOn(Object, 'hasOwn').mockImplementation((object: object, property: PropertyKey): boolean => { - if ((object as Record)['op'] === 'replace') throw 'validator failed' + if ((object as Record)['op'] === 'replace') throw failure return originalHasOwn(object, property) }) const seed = [{ @@ -329,7 +332,7 @@ describe('Session', () => { try { expect(() => new Session(SessionId('seed-non-error-metadata-failure'), seed)) - .toThrow('invalid seed event at index 0: invalid surface metadata') + .toThrow(`invalid seed event at index 0: ${expected}`) } finally { hasOwn.mockRestore() } @@ -468,7 +471,7 @@ describe('Session', () => { 'turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }, { surfaceOp: 'append' }, - )).toThrow(/not surface-eligible and cannot carry surface metadata/) + )).toThrow(/not surface-eligible and cannot carry surfaceOp/) expect(() => new Session(SessionId('non-surface-metadata-seed'), [{ type: 'turn/start', seq: 0, diff --git a/packages/core/session/tests/surface.spec.ts b/packages/core/session/tests/surface.spec.ts index fe471c29ad..fa7b1ff3ce 100644 --- a/packages/core/session/tests/surface.spec.ts +++ b/packages/core/session/tests/surface.spec.ts @@ -6,7 +6,7 @@ import { foldSurface, isSurfaceEligibleType, isSurfaceEvent, - validateSurfaceProvenance, + validateSurfaceMetadata, } from '@deepseek-ai/dsh-session' import { CallId } from '@deepseek-ai/dsh-llm' @@ -31,11 +31,11 @@ function provenanceEvent(seq: number, sourceEventSeqs: unknown): SessionEvent { } as unknown as SessionEvent } -describe('validateSurfaceProvenance', () => { +describe('validateSurfaceMetadata', () => { it('accepts absent or valid provenance and complete replacement coverage', () => { - expect(validateSurfaceProvenance(provenanceEvent(0, undefined), new Set())) + expect(validateSurfaceMetadata(provenanceEvent(0, undefined), new Set())) .toBeUndefined() - expect(validateSurfaceProvenance(provenanceEvent(2, [0, 1]), new Set([0, 1]), [1])) + expect(validateSurfaceMetadata(provenanceEvent(2, [0, 1]), new Set([0, 1]), [1])) .toBeUndefined() }) @@ -47,28 +47,33 @@ describe('validateSurfaceProvenance', () => { data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }, sourceEventSeqs: [0], } as unknown as SessionEvent - expect(validateSurfaceProvenance(event, new Set([0]))) - .toMatch(/cannot carry sourceEventSeqs/) + expect(validateSurfaceMetadata(event, new Set([0]))) + .toEqual({ + kind: 'provenance', + message: 'turn/start cannot carry sourceEventSeqs (non-surface event)', + }) }) it.each([ ['a non-array', 1, 'invalid', new Set([0]), [], /must be an array/], ['an empty array', 1, [], new Set([0]), [], /must not be empty/], ['duplicates', 1, [0, 0], new Set([0]), [], /must not contain duplicates/], - ['a non-number', 1, ['0'], new Set([0]), [], /invalid seq 0/], - ['a fractional number', 1, [0.5], new Set([0]), [], /invalid seq 0\.5/], - ['a negative number', 1, [-1], new Set([0]), [], /invalid seq -1/], + ['a non-number', 1, ['0'], new Set([0]), [], /non-negative safe integers/], + ['a fractional number', 1, [0.5], new Set([0]), [], /non-negative safe integers/], + ['a negative number', 1, [-1], new Set([0]), [], /non-negative safe integers/], ['a self reference', 1, [1], new Set([0]), [], /must reference earlier events/], ['an unknown earlier seq', 2, [1], new Set([0]), [], /references unknown seq 1/], ['incomplete replacement coverage', 2, [0], new Set([0, 1]), [0, 1], /missing 1/], ] as const)( 'returns the first violation for %s', (_name, seq, sources, knownSeqs, shadowedSeqs, expected) => { - expect(validateSurfaceProvenance( + const violation = validateSurfaceMetadata( provenanceEvent(seq, sources), knownSeqs, shadowedSeqs, - )).toMatch(expected) + ) + expect(violation?.kind).toBe('provenance') + expect(violation?.message).toMatch(expected) }, ) }) @@ -124,7 +129,20 @@ describe('SurfaceManager', () => { } expect(() => foldSurface([malformed])) - .toThrow(/surface event "user\/message" \(seq 0\) carries no surfaceOp marker/) + .toThrow(/surface-eligible and requires a surfaceOp marker/) + }) + + it('foldSurface rejects surfaceOp on a non-surface event', () => { + const malformed = { + type: 'turn/start', + seq: 0, + time: 1, + data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }, + surfaceOp: 'append', + } as unknown as SessionEvent + + expect(() => foldSurface([malformed])) + .toThrow(/not surface-eligible and cannot carry surfaceOp/) }) it('rebuilds a linked list from surfaceOp: append markers', () => { diff --git a/packages/session-query/session-query/README.md b/packages/session-query/session-query/README.md index 003fc9a839..2dcdec085e 100644 --- a/packages/session-query/session-query/README.md +++ b/packages/session-query/session-query/README.md @@ -14,7 +14,7 @@ This is trusted context-wide infrastructure. It performs no caller authorization Persistence is optional and may mount or unmount dynamically. Cross-corpus listing and lineage tracing fail with `SESSION_QUERY_PERSISTENCE_FAILED` while mounted persistence is unreadable. An event read or trace targeting a known live session does not consult persistence, so durable backend health cannot make current in-memory history unreadable. Persisted event operations list before loading and reject a metadata mismatch rather than combining inconsistent observations. -`traceEvent()` validates the whole loaded log with `dsh-session`'s shared provenance checker before returning relationships: provenance arrays are nonempty and duplicate-free, references name known earlier events, only surface event types carry sources, and each positional replacement names every surface node it removed. Provenance violations fail with `SESSION_QUERY_INVALID_PROVENANCE`; positional fold failures remain `SESSION_QUERY_INVALID_SURFACE`. `listEvents()` only needs surface classification and deliberately does not enforce the trace-specific provenance contract. +`traceEvent()` validates the whole loaded log with `dsh-session`'s shared surface-metadata checker before returning relationships: surface markers obey event-type eligibility, provenance arrays are nonempty and duplicate-free, references name known earlier events, and each positional replacement names every surface node it removed. Surface-marker and positional-fold violations fail with `SESSION_QUERY_INVALID_SURFACE`; provenance violations use `SESSION_QUERY_INVALID_PROVENANCE`. `listEvents()` only needs surface classification and deliberately does not enforce the trace-specific provenance contract. `SessionQueryError.code` is a closed union: `SESSION_QUERY_EVENT_NOT_FOUND`, `SESSION_QUERY_INVALID_CONFIG`, `SESSION_QUERY_INVALID_LINEAGE`, `SESSION_QUERY_INVALID_PROVENANCE`, `SESSION_QUERY_INVALID_SURFACE`, `SESSION_QUERY_INVALID_WINDOW`, `SESSION_QUERY_PERSISTENCE_FAILED`, `SESSION_QUERY_SESSION_NOT_FOUND`, and `SESSION_QUERY_SOURCE_CONFLICT`. diff --git a/packages/session-query/session-query/src/tracing.ts b/packages/session-query/session-query/src/tracing.ts index 8327877ee3..09d3ed65f6 100644 --- a/packages/session-query/session-query/src/tracing.ts +++ b/packages/session-query/session-query/src/tracing.ts @@ -1,6 +1,6 @@ /** One-shot session-lineage and event-relationship tracing helpers. */ -import { foldSurface, validateSurfaceProvenance } from '@deepseek-ai/dsh-session' +import { foldSurface, validateSurfaceMetadata } from '@deepseek-ai/dsh-session' import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session' import { SessionQueryError } from './config.ts' import type { @@ -53,14 +53,14 @@ export function traceEventLog( const analysis = analyzeEventLog(sessionId, events) const knownSeqs = new Set() for (const event of events) { - const violation = validateSurfaceProvenance( + const violation = validateSurfaceMetadata( event, knownSeqs, analysis.replacedEventSeqs.get(event.seq), ) if (violation !== undefined) { throw new SessionQueryError( - `invalid session provenance: ${violation}`, + `invalid session provenance: ${violation.message}`, 'SESSION_QUERY_INVALID_PROVENANCE', ) } diff --git a/packages/session-query/session-query/tests/tracing.spec.ts b/packages/session-query/session-query/tests/tracing.spec.ts index 8dd67e4893..0a8b8e6da1 100644 --- a/packages/session-query/session-query/tests/tracing.spec.ts +++ b/packages/session-query/session-query/tests/tracing.spec.ts @@ -390,6 +390,23 @@ describe('session event tracing', () => { .rejects.toThrow(expectCode('SESSION_QUERY_INVALID_PROVENANCE')) }) + it('rejects surfaceOp on a non-surface event as an invalid surface', async () => { + const durable = header('invalid-non-surface-op') + const events = [{ + type: 'turn/start', + seq: 0, + time: 1, + data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }, + surfaceOp: 'append', + }] as unknown as SessionEvent[] + TracePersistence.reset([{ meta: durable, events }]) + const ctx = await queryContext() + await ctx.plugin(TracePersistence) + + await expect(ctx.sessionQuery.traceEvent({ sessionId: durable.id, seq: 0 })) + .rejects.toThrow(expectCode('SESSION_QUERY_INVALID_SURFACE')) + }) + it('keeps listEvents tolerant of malformed provenance alone', async () => { const durable = header('list-regression') TracePersistence.reset([{ meta: durable, events: [appendEvent(0), appendEvent(1, [0, 0])] }]) diff --git a/packages/support/invariants/README.md b/packages/support/invariants/README.md index 8e19cfd79c..2fe2db1f6c 100644 --- a/packages/support/invariants/README.md +++ b/packages/support/invariants/README.md @@ -28,7 +28,7 @@ await ctx.plugin(Invariants) Session log (per session): - **`seq` strictly increases** — the spine of replay equivalence. -- **surface provenance is valid** — `sourceEventSeqs` uses the shared `dsh-session` checker for type eligibility, nonempty unique earlier references, and complete replacement coverage. +- **surface metadata is valid** — `surfaceOp` and `sourceEventSeqs` use the shared `dsh-session` checker for type eligibility, structural shape, nonempty unique earlier references, and complete replacement coverage. - **turns pair and nest** — `turn/start` opens a turn, `turn/end` closes the matching one; no overlapping turns. - **steps nest in turns** — `step/start` opens a step in the open turn; `step/end` closes the matching step. - **chunks belong to an open step** — `step/start` precedes its `assistant/chunk`s. diff --git a/packages/support/invariants/src/index.ts b/packages/support/invariants/src/index.ts index 9a3c67b97e..b2fa15080f 100644 --- a/packages/support/invariants/src/index.ts +++ b/packages/support/invariants/src/index.ts @@ -26,8 +26,7 @@ import { Session, SessionId, foldRequestHeader, - isSurfaceEligibleType, - validateSurfaceProvenance, + validateSurfaceMetadata, } from '@deepseek-ai/dsh-session' import type { SessionEvent, SurfaceEventType } from '@deepseek-ai/dsh-session' @@ -131,9 +130,8 @@ function validateEvent(trace: SessionTrace, event: SessionEvent): SessionTraceTr // SurfaceEvent's mandatory surfaceOp is too strict here — we need to // CHECK whether surface metadata is present, not assume it. const se = event as SessionEvent - if (!isSurfaceEligibleType(event.type) && se.surfaceOp !== undefined) { - throw new InvariantError(`${event.type} cannot carry surfaceOp (non-surface event)`) - } + const metadataViolation = validateSurfaceMetadata(event) + if (metadataViolation !== undefined) throw new InvariantError(metadataViolation.message) // Fold this event into the tracked surface linked list, validating the // replace contract as we go. `append` adds a tail node; `replace` shadows a @@ -160,13 +158,13 @@ function validateEvent(trace: SessionTrace, event: SessionEvent): SessionTraceTr } } - const provenanceViolation = validateSurfaceProvenance( + const provenanceViolation = validateSurfaceMetadata( event, trace.knownSeqs, shadowed, ) if (provenanceViolation !== undefined) { - throw new InvariantError(provenanceViolation) + throw new InvariantError(provenanceViolation.message) } // Boundary/step-scoped events have explicit cases; every OTHER event type — From dbe65e1d13fb8c4d482177b60fab7cd552c1facc Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Tue, 14 Jul 2026 13:49:36 +0800 Subject: [PATCH 07/12] Unify session surface validation --- docs/cordis-catalog/services.md | 2 +- docs/core-data-structures/session-query.md | 1 - .../2026-07-13-session-query-tracing.md | 2 +- packages/core/session/README.md | 7 +- packages/core/session/src/index.ts | 57 ++-- packages/core/session/src/surface.ts | 312 +++++++++--------- packages/core/session/tests/session.spec.ts | 27 +- packages/core/session/tests/surface.spec.ts | 125 ++++--- .../core/session/tests/tool-pairing.spec.ts | 3 +- .../session-query/session-query/README.md | 4 +- .../session-query/session-query/src/config.ts | 1 - .../session-query/src/tracing.ts | 34 +- .../session-query/tests/session-query.spec.ts | 14 +- .../session-query/tests/tracing.spec.ts | 12 +- packages/support/invariants/README.md | 3 +- packages/support/invariants/src/index.ts | 91 +---- .../invariants/tests/invariants.spec.ts | 37 +-- 17 files changed, 327 insertions(+), 405 deletions(-) diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index e62a96a7bb..b948ea898f 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -247,7 +247,7 @@ list(): Session[] fork(source: SessionForkSource, boundary?: number, childSessionId?: SessionId): Session ``` -Source: [`packages/core/session/src/index.ts:557`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:550`](../../packages/core/session/src/index.ts) ## `ctx.skills` — `SkillService` diff --git a/docs/core-data-structures/session-query.md b/docs/core-data-structures/session-query.md index 71fb956dcb..86d2259f7f 100644 --- a/docs/core-data-structures/session-query.md +++ b/docs/core-data-structures/session-query.md @@ -112,7 +112,6 @@ export type SessionQueryErrorCode = | 'SESSION_QUERY_EVENT_NOT_FOUND' | 'SESSION_QUERY_INVALID_CONFIG' | 'SESSION_QUERY_INVALID_LINEAGE' - | 'SESSION_QUERY_INVALID_PROVENANCE' | 'SESSION_QUERY_INVALID_SURFACE' | 'SESSION_QUERY_INVALID_WINDOW' | 'SESSION_QUERY_PERSISTENCE_FAILED' diff --git a/docs/rfc/implemented/feature/2026-07-13-session-query-tracing.md b/docs/rfc/implemented/feature/2026-07-13-session-query-tracing.md index ff2e9b7c23..9ebb8ee961 100644 --- a/docs/rfc/implemented/feature/2026-07-13-session-query-tracing.md +++ b/docs/rfc/implemented/feature/2026-07-13-session-query-tracing.md @@ -16,7 +16,7 @@ Session relationships are encoded across immutable headers, positional surface o ## Validation boundary -Event tracing checks target existence before surface analysis. Before returning a trace it validates the whole loaded log through `dsh-session`'s shared surface-metadata checker: surface markers obey event-type eligibility, provenance belongs only to surface event types, present arrays are nonempty and duplicate-free, every source is a known earlier seq, and every positional replacement names all surface nodes it removed. Surface-marker and positional-fold failures use `SESSION_QUERY_INVALID_SURFACE`; provenance failures use `SESSION_QUERY_INVALID_PROVENANCE`. `listEvents()` remains a surface-classification operation and does not acquire trace-specific provenance rejection. +Event tracing checks target existence before surface analysis. Both event listing and tracing then use `dsh-session`'s one-pass surface fold, which accepts or rejects the loaded log as a whole: surface markers obey event-type eligibility, provenance belongs only to surface event types, present arrays are nonempty and duplicate-free, every source is a known earlier seq, and every positional replacement names and cites all surface nodes it removes. Every contract failure uses `SESSION_QUERY_INVALID_SURFACE`; there is no weaker classification-only surface standard. All returned records and arrays are detached. A known live event trace never consults persistence; persisted event traces preserve the exact-read list/load consistency check. Session lineage is necessarily a cross-corpus operation and therefore preserves cross-corpus persistence failure semantics. diff --git a/packages/core/session/README.md b/packages/core/session/README.md index fc7e6d4300..7632212621 100644 --- a/packages/core/session/README.md +++ b/packages/core/session/README.md @@ -32,10 +32,10 @@ The store pairs announced creation with disposal, publishes each append, and pro Plain class (not a Cordis Service). Create via `ctx.sessions.create()`. -- `session.append(type, data, opts?): SessionEvent` — synchronous, never blocks on I/O. At this durable boundary, data and surface metadata are lossless-JSON snapshotted and deep-frozen. For an attached session, a reentrant append during dispatch/observer publication rejects, and detach waits for that publication to unwind. Callbacks resolve before the log push; the push is the commit point, after which each observer failure is contained independently. Runtime surface validation covers widened unions and raw seed/load logs. +- `session.append(type, data, opts?): SessionEvent` — synchronous, never blocks on I/O. At this durable boundary, data and surface metadata are lossless-JSON snapshotted and deep-frozen, then the same atomic surface transition used by replay validates marker shape, provenance, and complete replacement coverage before the log changes. For an attached session, a reentrant append during dispatch/observer publication rejects, and detach waits for that publication to unwind. Callbacks resolve before the log push; the push is the commit point, after which each observer failure is contained independently. - `session.deriveMessages(): Message[]` — the LLM message history, CACHED: each surface node is projected exactly once, when first seen (O(new nodes) per call; a surface rewrite rebuilds via `surface.replaceGeneration`). Returns a fresh array per call over shared, deep-frozen `Message` objects. Each projection reuses the already deep-frozen content in its durable log event, so no second deep clone is needed and a consumer still cannot mutate logged data. The surface is the single source of derived history — there is no raw-log fallback. - `session.deriveEventMessage(event): Message | null` — the per-event projection `deriveMessages()` folds: a fresh message wrapper that reuses the event's already frozen content, or `null` when the event produces none (a non-surface event, or an empty-content `assistant/message` hosting only usage). External reconstructors and the dev invariant fold the same function over a log prefix's surface, so no two paths can disagree about what a request's messages were (the reconstructability RFC). -- `session.surface: SurfaceManager` — the derived surface, lazily rebuilt from `surfaceOp` markers in the log. Processes only new events (delta) on each access — the log is append-only, so prior events never change. `surface.replaceGeneration` is the rewrite signal: bumped by every folded `replace` and by `invalidate()`, never reset, so an incremental consumer comparing generations cannot be fooled. +- `session.surface: SurfaceManager` — the derived surface, lazily rebuilt from `surfaceOp` markers in the log. It processes only new events (delta) on each access; event acceptance uses a separate manager with the same transition so validation does not eagerly mutate this public view. `surface.replaceGeneration` is the rewrite signal: bumped by every folded `replace` and by `invalidate()`, never reset, so an incremental consumer comparing generations cannot be fooled. - `session.events` — a cached, frozen array snapshot over deep-frozen events. Repeated reads without an append return the same array; an append invalidates the cache and the next read returns a new snapshot, while earlier snapshots stay unchanged. Neither a cast nor a retained reference can push into the live log or rewrite an accepted event. - `session.seq`, `session.id` — current sequence and readonly typed identity. - `session.header: SessionHeader` — detached, deep-frozen creation metadata (`version`, `id`, `createdAt`, optional `cwd`/`parentSession`/`seedLength`). Construction validates the durable record and requires its id to match `session.id`. @@ -49,8 +49,7 @@ Durable values need one accepted representation, not a check followed by a secon - `SurfaceOp` — how a surface node entered the linked list: `'append'` (normal tail append) or `{ op: 'replace', start, end }` (replace nodes from `start` through `end` inclusive — both must be valid surface node seqs; `start === end` replaces a single node). Used by compaction to shadow old nodes without deleting them. - `SurfaceIntent` — `{ surfaceOp: SurfaceOp; sourceEventSeqs?: number[] }`, the required third parameter to `session.append()` for surface-eligible types. - `SurfaceNode` — `{ seq: number; prev: number | null; next: number | null }`, one node in the surface linked list. -- `foldSurface(events)` — replay the canonical surface transitions into detached current nodes and actual replacement ranges, rejecting misplaced, missing, malformed, or positionally invalid `surfaceOp` metadata while leaving provenance validation to callers. `SurfaceManager` shares the same transitions while retaining its incremental cache. -- `validateSurfaceMetadata(event, knownSeqs?, shadowedSeqs?)` — canonical structural and provenance check shared by session acceptance, surface folding, incremental invariants, and exact readers. It tags violations as `surface` or `provenance` so callers retain their error taxonomy; omit `knownSeqs` for local shape validation only. +- `foldSurface(events)` — replay the one canonical surface contract into detached current nodes and actual replacement ranges. The same pass rejects misplaced or malformed metadata, empty or duplicate provenance, unknown or non-earlier sources, invalid positional ranges, and replacements that fail to cite every shadowed surface node; `SurfaceManager` shares the atomic transition while retaining its incremental cache. - `isSurfaceEvent(event)` / `isSurfaceEligibleType(type)` — the first narrows a `SessionEvent` to a fully-formed surface node (type is surface-eligible AND `surfaceOp` present); the second is the type-only check (is this one of the five `SurfaceEventType` values?), used to detect a surface-eligible event MISSING its marker — e.g. when validating a seed/load log. ### Request-header reconstruction (`request-header.ts`) diff --git a/packages/core/session/src/index.ts b/packages/core/session/src/index.ts index 88b3c15aff..fc3a7fcd29 100644 --- a/packages/core/session/src/index.ts +++ b/packages/core/session/src/index.ts @@ -15,7 +15,7 @@ import type { ContentBlock, Message, MessageSource } from '@deepseek-ai/dsh-llm' import { SESSION_FORMAT_VERSION, SessionId } from './types.ts' import type { CreateSessionOptions, EpochHeader, SessionEvent, SessionEventMap, SessionEventType, SessionHeader, SurfaceIntent, SurfaceEventType } from './types.ts' import { snapshotJsonValue } from './json.ts' -import { SurfaceManager, validateSurfaceMetadata } from './surface.ts' +import { SurfaceManager } from './surface.ts' import { foldRequestHeader } from './request-header.ts' export * from './types.ts' @@ -23,7 +23,7 @@ export { isJsonValue, snapshotJsonValue } from './json.ts' export type { JsonValue } from './json.ts' export { interruptedTurnClosers } from './repair.ts' export type { SurfaceFoldReplacement, SurfaceFoldResult, SurfaceNode } from './surface.ts' -export { foldSurface, isSurfaceEvent, isSurfaceEligibleType, validateSurfaceMetadata } from './surface.ts' +export { foldSurface, isSurfaceEvent, isSurfaceEligibleType } from './surface.ts' export { isToolPairingBalanced } from './tool-pairing.ts' export { applyHeaderDelta, canonicalHeader, diffHeader, foldRequestHeader, headerEquals } from './request-header.ts' @@ -223,13 +223,15 @@ const attachments = new WeakMap() */ export class Session { private log: SessionEvent[] = [] + /** Incremental acceptance state, kept separate from the public lazy view. */ + private readonly surfaceValidator = new SurfaceManager(this.log) /** * Derived surface — a cached linked list of message-producing events. * Lazily rebuilt from `surfaceOp` markers in the log; processes only new * events (delta) on each access — the log is append-only, so prior events * never change. - * `append`. Undefined until first accessed (including after fork/seed). + * Undefined until first accessed (including after fork/seed). */ private _surface: SurfaceManager | undefined @@ -258,7 +260,7 @@ export class Session { // `seq = log.length` contract the whole system relies on). Without this, // a bad seed would surface only later as a backend rejection or a silent // divergence between the live log and disk. - this.log = Array.from(seed, (source, index) => { + for (const [index, source] of seed.entries()) { // The seed is a persistence/replay boundary: validate and detach the // complete event in one lossless-JSON pass. const snapshot = snapshotJsonValue(source) @@ -269,23 +271,16 @@ export class Session { if (snapshot.seq !== index) { throw new Error(`seed event at index ${index} has seq ${snapshot.seq} (expected ${index}); seed must be contiguous from 0`) } - // Surface-eligible events MUST carry a surfaceOp marker — the surface is - // the sole source of derived history, so a marker-less message event - // would load fine yet vanish from deriveMessages(). `append` enforces - // this at compile time via its typed overload; a seed arrives as raw - // SessionEvent[] (replay/fork/load), bypassing that, so re-check at - // runtime here rather than silently resuming with empty history. - let violation: ReturnType + // A seed is accepted incrementally through the same transition as a + // live append and a full-log fold. The candidate is planned before it + // enters `log`, so a failure cannot partially mutate the surface. try { - violation = validateSurfaceMetadata(snapshot) + this.surfaceValidator.validateNext(snapshot) } catch (error: unknown) { throw new Error(`invalid seed event at index ${index}: ${error instanceof Error ? error.message : 'invalid surface metadata'}`) } - if (violation !== undefined) { - throw new Error(`invalid seed event at index ${index}: ${violation.message}`) - } - return deepFreeze(snapshot) - }) + this.log.push(deepFreeze(snapshot)) + } } this.header = snapshotSessionHeader(id, header) } @@ -332,7 +327,10 @@ export class Session { * @throws if `data` or surface metadata is not losslessly JSON-serializable * (BigInt, function, symbol, undefined, negative zero, non-finite number, * circular reference, sparse array, or an exotic object such as - * Map/Set/Date/class instance). One recursive pass reads, validates, and + * Map/Set/Date/class instance), or when the candidate violates the + * canonical surface contract (marker shape and eligibility, unique known + * earlier provenance, positional replacement validity, and complete + * shadowed-node coverage). One recursive pass reads, validates, and * copies each nested value once, so a stateful getter cannot supply one value * to validation and another to storage. The event log is the durable source * of truth, so a bad event fails at the append site rather than later during @@ -358,26 +356,21 @@ export class Session { if (surfaceMetadataSnapshot === undefined) { throw new Error(`session event "${type}" carries non-JSON-serializable surface metadata`) } - const surfaceViolation = validateSurfaceMetadata({ - type, - seq: this.log.length, - ...(surfaceMetadataSnapshot as { surfaceOp?: unknown; sourceEventSeqs?: unknown }), - }) - if (surfaceViolation !== undefined) throw new Error(surfaceViolation.message) - const entry = attachments.get(this) if (entry?.appending) { throw new Error('session append cannot reenter while another append is being published') } + const event = deepFreeze({ + type, + seq: this.log.length, + time: Date.now(), + data: dataSnapshot, + ...(surfaceMetadataSnapshot as { surfaceOp?: unknown; sourceEventSeqs?: unknown }), + } as unknown as SessionEvent) + this.surfaceValidator.validateNext(event as SessionEvent) + if (entry !== undefined) entry.appending = true try { - const event = deepFreeze({ - type, - seq: this.log.length, - time: Date.now(), - data: dataSnapshot, - ...surfaceMetadataSnapshot, - } as unknown as SessionEvent) let callbacks: SessionCallback[] | undefined const callbackArgs: unknown[] = [this, event] if (entry !== undefined) { diff --git a/packages/core/session/src/surface.ts b/packages/core/session/src/surface.ts index c8642d18fc..7e1da76832 100644 --- a/packages/core/session/src/surface.ts +++ b/packages/core/session/src/surface.ts @@ -81,165 +81,118 @@ export interface SurfaceFoldResult { replacements: SurfaceFoldReplacement[] } -/** - * Validate one event's surface metadata through the canonical structural and - * provenance contract. Structural validation always runs; when `knownSeqs` is - * supplied, provenance must additionally name unique known earlier events and - * cover every shadowed surface node. The tagged result lets callers retain - * their own surface-versus-provenance error taxonomy. - * @param event - event whose `surfaceOp` and `sourceEventSeqs` are being checked. - * @param knownSeqs - seqs preceding `event`, or `undefined` for local shape validation only. - * @param shadowedSeqs - surface nodes directly removed by this event. - * @returns the first tagged contract violation, or `undefined` when valid. - */ -export function validateSurfaceMetadata( - event: Pick & { - surfaceOp?: unknown - sourceEventSeqs?: unknown - }, - knownSeqs?: ReadonlySet, - shadowedSeqs: readonly number[] = [], -): { kind: 'surface' | 'provenance'; message: string } | undefined { - const eligible = isSurfaceEligibleType(event.type) - const surfaceOp = event.surfaceOp - const sources = event.sourceEventSeqs - - if (!eligible && surfaceOp !== undefined) { - return { - kind: 'surface', - message: `session event "${event.type}" is not surface-eligible and cannot carry surfaceOp`, - } - } - if (eligible && surfaceOp === undefined) { - return { - kind: 'surface', - message: `session event "${event.type}" is surface-eligible and requires a surfaceOp marker`, - } - } - if (surfaceOp !== undefined && surfaceOp !== 'append') { - if (surfaceOp === null || typeof surfaceOp !== 'object' || Array.isArray(surfaceOp)) { - return { - kind: 'surface', - message: `session event "${event.type}" carries an invalid surfaceOp`, - } - } - const op = surfaceOp as Record - const keys = Object.keys(op) - if (keys.length !== 3 || !Object.hasOwn(op, 'op') || !Object.hasOwn(op, 'start') || !Object.hasOwn(op, 'end') - || op['op'] !== 'replace' - || typeof op['start'] !== 'number' || !Number.isSafeInteger(op['start']) || op['start'] < 0 - || typeof op['end'] !== 'number' || !Number.isSafeInteger(op['end']) || op['end'] < 0) { - return { - kind: 'surface', - message: `session event "${event.type}" carries an invalid replace surfaceOp`, - } - } - } - - if (sources !== undefined && !eligible) { - return { - kind: 'provenance', - message: `${event.type} cannot carry sourceEventSeqs (non-surface event)`, - } - } - if (sources !== undefined && !Array.isArray(sources)) { - return { - kind: 'provenance', - message: `sourceEventSeqs on event at seq ${event.seq} must be an array when present`, - } - } - if (Array.isArray(sources) - && sources.some(source => typeof source !== 'number' || !Number.isSafeInteger(source) || source < 0)) { - return { - kind: 'provenance', - message: `session event "${event.type}" sourceEventSeqs must contain non-negative safe integers`, - } - } - if (knownSeqs === undefined) return - - const sourceSeqs = sources as number[] | undefined - if (sourceSeqs !== undefined && sourceSeqs.length === 0) { - return { kind: 'provenance', message: 'sourceEventSeqs must not be empty when present' } - } - - const unique = new Set() - for (const source of sourceSeqs ?? []) { - if (unique.has(source)) { - return { kind: 'provenance', message: 'sourceEventSeqs must not contain duplicates' } - } - unique.add(source) - if (source >= event.seq) { - return { - kind: 'provenance', - message: `sourceEventSeqs must reference earlier events: ${source} >= current seq ${event.seq}`, - } - } - if (!knownSeqs.has(source)) { - return { kind: 'provenance', message: `sourceEventSeqs references unknown seq ${source}` } - } - } - - const sourceSet = new Set(sourceSeqs ?? []) - const missing = shadowedSeqs.filter(seq => !sourceSet.has(seq)) - if (missing.length > 0) { - return { - kind: 'provenance', - message: `surface replace: sourceEventSeqs must include every shadowed surface node; missing ${missing.join(', ')}`, - } - } - return undefined -} - /** Mutable state shared by the incremental manager and the full-log fold. */ interface SurfaceFoldState { nodes: SurfaceNode[] nodeBySeq: Map + knownSeqs: Set replaceGeneration: number } +/** A validated replacement transition that has not mutated fold state yet. */ +interface SurfaceReplacePlan extends SurfaceFoldReplacement { + kind: 'replace' + startIdx: number + endIdx: number +} + +/** One validated surface transition that has not mutated fold state yet. */ +type SurfacePlan = + | { kind: 'append'; seq: number } + | SurfaceReplacePlan + /** Create an empty surface fold state. */ function createFoldState(replaceGeneration = 0): SurfaceFoldState { return { nodes: [], nodeBySeq: new Map(), + knownSeqs: new Set(), replaceGeneration, } } -/** Apply one event and return replacement metadata only when one occurred. */ -function applySurfaceEvent( - state: SurfaceFoldState, - event: SessionEvent, -): SurfaceFoldReplacement | undefined { - const violation = validateSurfaceMetadata(event) - if (violation?.kind === 'surface') throw new Error(violation.message) - if (!isSurfaceEligibleType(event.type)) return - // The canonical metadata validation above proves this runtime shape. - const surfaceEvent = event as SurfaceEvent +/** Whether a runtime value is a non-negative safe event sequence. */ +function isEventSeq(value: unknown): value is number { + return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 +} - if (surfaceEvent.surfaceOp === 'append') { - const tail = state.nodes.length > 0 ? state.nodes[state.nodes.length - 1] : undefined - const node: SurfaceNode = { seq: surfaceEvent.seq, prev: tail?.seq ?? null, next: null } - if (tail) tail.next = surfaceEvent.seq - state.nodes.push(node) - state.nodeBySeq.set(surfaceEvent.seq, node) +/** Whether a runtime value is the exact positional-replacement shape. */ +function isReplaceOp(value: object): value is Extract { + const op = value as Record + return Object.keys(op).length === 3 + && Object.hasOwn(op, 'op') + && Object.hasOwn(op, 'start') + && Object.hasOwn(op, 'end') + && op['op'] === 'replace' + && isEventSeq(op['start']) + && isEventSeq(op['end']) +} + +/** Validate event-local metadata and narrow a surface-eligible event. */ +function surfaceEventOf(event: SessionEvent): SurfaceEvent | undefined { + const raw = event as SessionEvent & { surfaceOp?: unknown; sourceEventSeqs?: unknown } + if (!isSurfaceEligibleType(event.type)) { + if (raw.surfaceOp !== undefined) { + throw new Error(`session event "${event.type}" is not surface-eligible and cannot carry surfaceOp`) + } + if (raw.sourceEventSeqs !== undefined) { + throw new Error(`session event "${event.type}" is not surface-eligible and cannot carry sourceEventSeqs`) + } return } + if (raw.surfaceOp === undefined) { + throw new Error(`session event "${event.type}" is surface-eligible and requires a surfaceOp marker`) + } + if (raw.surfaceOp !== 'append') { + if (raw.surfaceOp === null || typeof raw.surfaceOp !== 'object' || Array.isArray(raw.surfaceOp)) { + throw new Error(`session event "${event.type}" carries an invalid surfaceOp`) + } + if (!isReplaceOp(raw.surfaceOp)) { + throw new Error(`session event "${event.type}" carries an invalid replace surfaceOp`) + } + } + if (raw.sourceEventSeqs !== undefined && !Array.isArray(raw.sourceEventSeqs)) { + throw new Error(`sourceEventSeqs on event at seq ${event.seq} must be an array when present`) + } + if (Array.isArray(raw.sourceEventSeqs) && !raw.sourceEventSeqs.every(isEventSeq)) { + throw new Error(`session event "${event.type}" sourceEventSeqs must contain non-negative safe integers`) + } + return event as SurfaceEvent +} - return { - seq: surfaceEvent.seq, - start: surfaceEvent.surfaceOp.start, - end: surfaceEvent.surfaceOp.end, - shadowedSeqs: replaceSurface(state, surfaceEvent.seq, surfaceEvent.surfaceOp), +/** Validate provenance against prior log entries and the replacement range. */ +function assertProvenance( + event: SurfaceEvent, + knownSeqs: ReadonlySet, + shadowedSeqs: readonly number[], +): void { + const sources = event.sourceEventSeqs + if (sources !== undefined && sources.length === 0) { + throw new Error('sourceEventSeqs must not be empty when present') + } + const sourceSet = new Set(sources ?? []) + if (sources !== undefined && sourceSet.size !== sources.length) { + throw new Error('sourceEventSeqs must not contain duplicates') + } + for (const source of sources ?? []) { + if (source >= event.seq) { + throw new Error(`sourceEventSeqs must reference earlier events: ${source} >= current seq ${event.seq}`) + } + if (!knownSeqs.has(source)) { + throw new Error(`sourceEventSeqs references unknown seq ${source}`) + } + } + const missing = shadowedSeqs.filter(seq => !sourceSet.has(seq)) + if (missing.length > 0) { + throw new Error(`surface replace: sourceEventSeqs must include every shadowed surface node; missing ${missing.join(', ')}`) } } -/** Apply one positional replacement and return the nodes it removed. */ -function replaceSurface( +/** Locate one replacement range without mutating the current fold state. */ +function replacementRange( state: SurfaceFoldState, - newSeq: number, op: Extract, -): number[] { +): Pick { const startNode = state.nodeBySeq.get(op.start) if (!startNode) { throw new Error(`surface replace: start seq ${op.start} not found in surface`) @@ -253,6 +206,35 @@ function replaceSurface( if (startIdx > endIdx) { throw new Error(`surface replace: start seq ${op.start} (index ${startIdx}) is after end seq ${op.end} (index ${endIdx})`) } + return { + startIdx, + endIdx, + shadowedSeqs: state.nodes.slice(startIdx, endIdx + 1).map(node => node.seq), + } +} + +/** Validate one event and prepare its atomic fold transition. */ +function planSurfaceEvent(state: SurfaceFoldState, event: SessionEvent): SurfacePlan | undefined { + const surfaceEvent = surfaceEventOf(event) + if (surfaceEvent === undefined) return + if (surfaceEvent.surfaceOp === 'append') { + assertProvenance(surfaceEvent, state.knownSeqs, []) + return { kind: 'append', seq: event.seq } + } + const range = replacementRange(state, surfaceEvent.surfaceOp) + assertProvenance(surfaceEvent, state.knownSeqs, range.shadowedSeqs) + return { + kind: 'replace', + seq: event.seq, + start: surfaceEvent.surfaceOp.start, + end: surfaceEvent.surfaceOp.end, + ...range, + } +} + +/** Apply one already-validated positional replacement. */ +function replaceSurface(state: SurfaceFoldState, plan: SurfaceReplacePlan): void { + const { startIdx, endIdx } = plan const removed = state.nodes.splice(startIdx, endIdx - startIdx + 1) for (const node of removed) state.nodeBySeq.delete(node.seq) @@ -260,16 +242,40 @@ function replaceSurface( const prevNode = startIdx > 0 ? state.nodes[startIdx - 1] : undefined const nextNode = startIdx < state.nodes.length ? state.nodes[startIdx] : undefined const newNode: SurfaceNode = { - seq: newSeq, + seq: plan.seq, prev: prevNode?.seq ?? null, next: nextNode?.seq ?? null, } - if (prevNode) prevNode.next = newSeq - if (nextNode) nextNode.prev = newSeq + if (prevNode) prevNode.next = plan.seq + if (nextNode) nextNode.prev = plan.seq state.nodes.splice(startIdx, 0, newNode) - state.nodeBySeq.set(newSeq, newNode) + state.nodeBySeq.set(plan.seq, newNode) state.replaceGeneration += 1 - return removed.map(node => node.seq) +} + +/** Apply one event and return replacement metadata only when one occurred. */ +function applySurfaceEvent( + state: SurfaceFoldState, + event: SessionEvent, +): SurfaceFoldReplacement | undefined { + const plan = planSurfaceEvent(state, event) + if (plan?.kind === 'append') { + const tail = state.nodes.at(-1) + const node: SurfaceNode = { seq: plan.seq, prev: tail?.seq ?? null, next: null } + if (tail) tail.next = plan.seq + state.nodes.push(node) + state.nodeBySeq.set(plan.seq, node) + } else if (plan?.kind === 'replace') { + replaceSurface(state, plan) + } + state.knownSeqs.add(event.seq) + if (plan?.kind !== 'replace') return + return { + seq: plan.seq, + start: plan.start, + end: plan.end, + shadowedSeqs: plan.shadowedSeqs, + } } /** @@ -280,8 +286,9 @@ function replaceSurface( * models cannot disagree with `deriveMessages()` about replacement ranges. * @param events - session events in contiguous seq order. * @returns the current surface and every positional replacement. - * @throws when an event violates the `surfaceOp` type/marker contract, or a - * replacement names nodes that are absent or reversed on the current surface. + * @throws when any event violates the unified surface contract: metadata must + * be well shaped and type-eligible, provenance must name unique known earlier + * events, and a positional replacement must name and cite its complete range. */ export function foldSurface(events: readonly SessionEvent[]): SurfaceFoldResult { const state = createFoldState() @@ -297,11 +304,10 @@ export function foldSurface(events: readonly SessionEvent[]): SurfaceFoldResult } /** - * Maintains a cached linked list of surface nodes, rebuilt lazily from - * `surfaceOp` markers in the event log. Because the log is append-only, it - * processes only the delta since the last rebuild — new events are folded - * into the existing surface in O(new events) rather than rescanning the - * whole log. + * Maintains a cached linked list of surface nodes and validates each candidate + * before it enters the event log. Because the log is append-only, it processes + * only committed deltas and plans the candidate without mutation rather than + * rescanning the whole log. */ export class SurfaceManager { /** Incremental state shared with the complete surface fold. */ @@ -311,6 +317,18 @@ export class SurfaceManager { constructor(private log: readonly SessionEvent[]) {} + /** + * Validate one candidate as the next log event without applying it. The + * committed log is folded first, then the candidate's complete surface and + * provenance transition is planned atomically; a failure leaves the current + * surface unchanged. + * @param event - candidate event that has not entered `log` yet. + */ + validateNext(event: SessionEvent): void { + if (this._lastProcessedSeq < this.log.length - 1) this._processDelta() + planSurfaceEvent(this._state, event) + } + /** * Reset to unprocessed state. Call after the log has been replaced * wholesale (e.g. after Session seed). Not needed for normal appends — @@ -353,7 +371,7 @@ export class SurfaceManager { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const event = this.log[i]! applySurfaceEvent(this._state, event) + this._lastProcessedSeq = i } - this._lastProcessedSeq = this.log.length - 1 } } diff --git a/packages/core/session/tests/session.spec.ts b/packages/core/session/tests/session.spec.ts index fe44de871f..e3e5cc76c2 100644 --- a/packages/core/session/tests/session.spec.ts +++ b/packages/core/session/tests/session.spec.ts @@ -301,12 +301,19 @@ describe('Session', () => { type: 'user/message', seq: 0, time: 1, + data: { content: [{ type: 'text', text: 'source' }], source: { kind: 'user' } }, + surfaceOp: 'append', + }, { + type: 'user/message', + seq: 1, + time: 2, data: { content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' } }, surfaceOp, + sourceEventSeqs: [0], }] as unknown as SessionEvent[] const session = new Session(SessionId('seed-unstable-metadata'), seed) - const event = session.events[0]! + const event = session.events[1]! if (event.type !== 'user/message') throw new Error('test fixture must remain a user/message') expect(reads).toBe(1) @@ -326,13 +333,20 @@ describe('Session', () => { type: 'user/message', seq: 0, time: 1, + data: { content: [{ type: 'text', text: 'source' }], source: { kind: 'user' } }, + surfaceOp: 'append', + }, { + type: 'user/message', + seq: 1, + time: 2, data: { content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' } }, surfaceOp: { op: 'replace', start: 0, end: 0 }, + sourceEventSeqs: [0], }] as unknown as SessionEvent[] try { expect(() => new Session(SessionId('seed-non-error-metadata-failure'), seed)) - .toThrow(`invalid seed event at index 0: ${expected}`) + .toThrow(`invalid seed event at index 1: ${expected}`) } finally { hasOwn.mockRestore() } @@ -418,6 +432,11 @@ describe('Session', () => { it('reads a nested append-metadata getter once and stores its first JSON value', () => { const session = new Session(SessionId('append-unstable-metadata')) + const source = session.append( + 'user/message', + { content: [{ type: 'text', text: 'source' }], source: { kind: 'user' } }, + { surfaceOp: 'append' }, + ) let reads = 0 const surfaceOp = Object.defineProperty({ op: 'replace', end: 0 }, 'start', { enumerable: true, @@ -430,12 +449,12 @@ describe('Session', () => { const event = session.append( 'user/message', { content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' } }, - { surfaceOp } as never, + { surfaceOp, sourceEventSeqs: [0] } as never, ) expect(reads).toBe(1) expect(event.surfaceOp).toEqual({ op: 'replace', start: 0, end: 0 }) - expect(session.events).toEqual([event]) + expect(session.events).toEqual([source, event]) }) it('rejects invalid plain surface metadata shapes at append', () => { diff --git a/packages/core/session/tests/surface.spec.ts b/packages/core/session/tests/surface.spec.ts index fa7b1ff3ce..e1f948f2b1 100644 --- a/packages/core/session/tests/surface.spec.ts +++ b/packages/core/session/tests/surface.spec.ts @@ -6,7 +6,6 @@ import { foldSurface, isSurfaceEligibleType, isSurfaceEvent, - validateSurfaceMetadata, } from '@deepseek-ai/dsh-session' import { CallId } from '@deepseek-ai/dsh-llm' @@ -27,53 +26,52 @@ function provenanceEvent(seq: number, sourceEventSeqs: unknown): SessionEvent { time: seq, data: { content: [], source: { kind: 'user' } }, surfaceOp: 'append', - sourceEventSeqs, + ...sourceEventSeqs === undefined ? {} : { sourceEventSeqs }, } as unknown as SessionEvent } -describe('validateSurfaceMetadata', () => { +describe('foldSurface provenance', () => { it('accepts absent or valid provenance and complete replacement coverage', () => { - expect(validateSurfaceMetadata(provenanceEvent(0, undefined), new Set())) - .toBeUndefined() - expect(validateSurfaceMetadata(provenanceEvent(2, [0, 1]), new Set([0, 1]), [1])) - .toBeUndefined() + const events = [ + provenanceEvent(0, undefined), + provenanceEvent(1, undefined), + { + ...provenanceEvent(2, [0, 1]), + surfaceOp: { op: 'replace', start: 0, end: 1 }, + }, + ] as SessionEvent[] + expect(() => foldSurface(events)).not.toThrow() }) it('rejects provenance on a non-surface event', () => { const event = { type: 'turn/start', - seq: 1, + seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }, sourceEventSeqs: [0], } as unknown as SessionEvent - expect(validateSurfaceMetadata(event, new Set([0]))) - .toEqual({ - kind: 'provenance', - message: 'turn/start cannot carry sourceEventSeqs (non-surface event)', - }) + expect(() => foldSurface([event])).toThrow(/cannot carry sourceEventSeqs/) }) it.each([ - ['a non-array', 1, 'invalid', new Set([0]), [], /must be an array/], - ['an empty array', 1, [], new Set([0]), [], /must not be empty/], - ['duplicates', 1, [0, 0], new Set([0]), [], /must not contain duplicates/], - ['a non-number', 1, ['0'], new Set([0]), [], /non-negative safe integers/], - ['a fractional number', 1, [0.5], new Set([0]), [], /non-negative safe integers/], - ['a negative number', 1, [-1], new Set([0]), [], /non-negative safe integers/], - ['a self reference', 1, [1], new Set([0]), [], /must reference earlier events/], - ['an unknown earlier seq', 2, [1], new Set([0]), [], /references unknown seq 1/], - ['incomplete replacement coverage', 2, [0], new Set([0, 1]), [0, 1], /missing 1/], + ['a non-array', [{ ...provenanceEvent(0, undefined), sourceEventSeqs: 'invalid' }], /must be an array/], + ['an empty array', [provenanceEvent(0, [])], /must not be empty/], + ['duplicates', [provenanceEvent(0, undefined), provenanceEvent(1, [0, 0])], /must not contain duplicates/], + ['a non-number', [{ ...provenanceEvent(0, undefined), sourceEventSeqs: ['0'] }], /non-negative safe integers/], + ['a fractional number', [provenanceEvent(0, [0.5])], /non-negative safe integers/], + ['a negative number', [provenanceEvent(0, [-1])], /non-negative safe integers/], + ['a self reference', [provenanceEvent(0, [0])], /must reference earlier events/], + ['an unknown earlier seq', [provenanceEvent(0, undefined), provenanceEvent(2, [1])], /references unknown seq 1/], + ['incomplete replacement coverage', [ + provenanceEvent(0, undefined), + provenanceEvent(1, undefined), + { ...provenanceEvent(2, [0]), surfaceOp: { op: 'replace', start: 0, end: 1 } }, + ], /missing 1/], ] as const)( - 'returns the first violation for %s', - (_name, seq, sources, knownSeqs, shadowedSeqs, expected) => { - const violation = validateSurfaceMetadata( - provenanceEvent(seq, sources), - knownSeqs, - shadowedSeqs, - ) - expect(violation?.kind).toBe('provenance') - expect(violation?.message).toMatch(expected) + 'rejects %s', + (_name, events, expected) => { + expect(() => foldSurface(events as unknown as SessionEvent[])).toThrow(expected) }, ) }) @@ -101,7 +99,7 @@ describe('SurfaceManager', () => { it('does not retain fold-only replacement history in incremental state', () => { const s = new Session(SessionId('incremental-state')) s.append('user/message', { content: [{ type: 'text', text: 'a' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) - s.append('assistant/message', { turn: 1, step: 1, content: [{ type: 'text', text: 'b' }] }, { surfaceOp: { op: 'replace', start: 0, end: 0 } }) + s.append('assistant/message', { turn: 1, step: 1, content: [{ type: 'text', text: 'b' }] }, { surfaceOp: { op: 'replace', start: 0, end: 0 }, sourceEventSeqs: [0] }) expect(s.surface.nodes).toEqual([{ seq: 1, prev: null, next: null }]) const manager = s.surface as unknown as { _state: object } @@ -112,12 +110,29 @@ describe('SurfaceManager', () => { }) it('foldSurface reports the same invalid replacement failures as the incremental manager', () => { - const s = new Session(SessionId('shared-fold-invalid')) - s.append('user/message', { content: [{ type: 'text', text: 'a' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) - s.append('assistant/message', { turn: 1, step: 1, content: [] }, { surfaceOp: { op: 'replace', start: 42, end: 0 }, sourceEventSeqs: [0] }) + const events = [ + provenanceEvent(0, undefined), + { ...provenanceEvent(1, [0]), surfaceOp: { op: 'replace', start: 42, end: 0 } }, + ] as SessionEvent[] - expect(() => foldSurface(s.events)).toThrow(/start seq 42 not found/) - expect(() => s.surface.nodes).toThrow(/start seq 42 not found/) + expect(() => foldSurface(events)).toThrow(/start seq 42 not found/) + expect(() => new Session(SessionId('shared-fold-invalid'), events)) + .toThrow(/start seq 42 not found/) + }) + + it('leaves incremental state unchanged when candidate validation fails', () => { + const s = new Session(SessionId('atomic-validation')) + s.append('user/message', { content: [{ type: 'text', text: 'a' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) + + expect(() => s.append( + 'assistant/message', + { turn: 1, step: 1, content: [{ type: 'text', text: 'invalid' }] }, + { surfaceOp: { op: 'replace', start: 0, end: 0 } }, + )).toThrow(/missing 0/) + + expect(s.events).toHaveLength(1) + s.append('user/message', { content: [{ type: 'text', text: 'b' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) + expect(s.surface.nodes.map(node => node.seq)).toEqual([0, 1]) }) it('foldSurface rejects a surface-eligible event without its mandatory marker', () => { @@ -250,21 +265,19 @@ describe('SurfaceManager', () => { it('throws when replace start is not found', () => { const s = new Session(SessionId('bad-start')) s.append('user/message', { content: [{ type: 'text', text: 'a' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) // seq 0 - s.append('assistant/message', + expect(() => s.append('assistant/message', { turn: 1, step: 1, content: [{ type: 'text', text: 'y' }] }, - { surfaceOp: { op: 'replace', start: 5, end: 0 }, sourceEventSeqs: [5, 0] }, - ) - expect(() => s.surface.nodes).toThrow(/surface replace: start seq 5 not found/) + { surfaceOp: { op: 'replace', start: 5, end: 0 }, sourceEventSeqs: [0] }, + )).toThrow(/surface replace: start seq 5 not found/) }) it('throws when replace end is not found', () => { const s = new Session(SessionId('bad-end')) s.append('user/message', { content: [{ type: 'text', text: 'a' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) // seq 0 - s.append('assistant/message', + expect(() => s.append('assistant/message', { turn: 1, step: 1, content: [{ type: 'text', text: 'y' }] }, { surfaceOp: { op: 'replace', start: 0, end: 99 }, sourceEventSeqs: [0] }, - ) - expect(() => s.surface.nodes).toThrow(/surface replace: end seq 99 not found/) + )).toThrow(/surface replace: end seq 99 not found/) }) it('throws when start is after end', () => { @@ -272,22 +285,22 @@ describe('SurfaceManager', () => { s.append('user/message', { content: [{ type: 'text', text: 'a' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) // seq 0 s.append('user/message', { content: [{ type: 'text', text: 'b' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) // seq 1 // start=1, end=0 would be reversed order. - s.append('assistant/message', + expect(() => s.append('assistant/message', { turn: 1, step: 1, content: [{ type: 'text', text: 'y' }] }, { surfaceOp: { op: 'replace', start: 1, end: 0 }, sourceEventSeqs: [1, 0] }, - ) - expect(() => s.surface.nodes).toThrow(/start seq 1.*after end seq 0/) + )).toThrow(/start seq 1.*after end seq 0/) }) it('sourceEventSeqs is snapshot so caller mutation does not affect logged event', () => { const s = new Session(SessionId('immutable')) - const sources = [10, 20] + s.append('user/message', { content: [{ type: 'text', text: 'source' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) + const sources = [0] s.append('assistant/message', { turn: 1, step: 1, content: [{ type: 'text', text: 'h' }] }, { surfaceOp: 'append', sourceEventSeqs: sources }) // Mutate caller's array after append. - sources.push(30) + sources.push(1) sources[0] = 99 - const logged = s.events[0]! as SurfaceEvent - expect(logged.sourceEventSeqs).toEqual([10, 20]) + const logged = s.events[1]! as SurfaceEvent + expect(logged.sourceEventSeqs).toEqual([0]) }) it('replace starting at non-head position links to previous node correctly', () => { @@ -369,15 +382,17 @@ describe('deriveMessages with surface', () => { describe('Session.append surface opts', () => { it('records sourceEventSeqs and surfaceOp on the event', () => { const s = new Session(SessionId('opts')) + s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + s.append('step/start', { turn: 1, step: 1 }) const event = s.append('assistant/message', { turn: 1, step: 1, content: [{ type: 'text', text: 'h' }] }, - { surfaceOp: 'append', sourceEventSeqs: [3, 5, 7] }, + { surfaceOp: 'append', sourceEventSeqs: [0, 1] }, ) - expect(event.sourceEventSeqs).toEqual([3, 5, 7]) + expect(event.sourceEventSeqs).toEqual([0, 1]) expect(event.surfaceOp).toBe('append') // The logged event matches the returned event. - expect((s.events[0]! as SurfaceEvent).sourceEventSeqs).toEqual([3, 5, 7]) - expect((s.events[0]! as SurfaceEvent).surfaceOp).toBe('append') + expect((s.events[2]! as SurfaceEvent).sourceEventSeqs).toEqual([0, 1]) + expect((s.events[2]! as SurfaceEvent).surfaceOp).toBe('append') }) it('deriveMessages skips a surface node that derives to null (empty assistant/message)', () => { diff --git a/packages/core/session/tests/tool-pairing.spec.ts b/packages/core/session/tests/tool-pairing.spec.ts index 307b0d8658..cc2acd1a2c 100644 --- a/packages/core/session/tests/tool-pairing.spec.ts +++ b/packages/core/session/tests/tool-pairing.spec.ts @@ -261,10 +261,11 @@ describe('isToolPairingBalanced — CBR-001: a head checkpoint left by a replace // summary user/message — appended now, so it carries a high log seq. const u1 = seqOf(s, 'user/message') const result = s.events.find(e => e.type === 'tool/result')!.seq + const shadowedSeqs = s.surface.nodes.map(node => node.seq) s.append('user/message', { content: [{ type: 'text', text: 'CHECKPOINT' }], source: { kind: 'plugin', plugin: 'compact' }, - }, { surfaceOp: { op: 'replace', start: u1, end: result } }) + }, { surfaceOp: { op: 'replace', start: u1, end: result }, sourceEventSeqs: shadowedSeqs }) // The step's own assistant/message lands AFTER the checkpoint in the log, // still inside the open step. s.append('assistant/message', { turn: 2, step: 1, content: [{ type: 'text', text: 'a2' }] }, SURFACE) diff --git a/packages/session-query/session-query/README.md b/packages/session-query/session-query/README.md index 2dcdec085e..4f9f8122b4 100644 --- a/packages/session-query/session-query/README.md +++ b/packages/session-query/session-query/README.md @@ -14,9 +14,9 @@ This is trusted context-wide infrastructure. It performs no caller authorization Persistence is optional and may mount or unmount dynamically. Cross-corpus listing and lineage tracing fail with `SESSION_QUERY_PERSISTENCE_FAILED` while mounted persistence is unreadable. An event read or trace targeting a known live session does not consult persistence, so durable backend health cannot make current in-memory history unreadable. Persisted event operations list before loading and reject a metadata mismatch rather than combining inconsistent observations. -`traceEvent()` validates the whole loaded log with `dsh-session`'s shared surface-metadata checker before returning relationships: surface markers obey event-type eligibility, provenance arrays are nonempty and duplicate-free, references name known earlier events, and each positional replacement names every surface node it removed. Surface-marker and positional-fold violations fail with `SESSION_QUERY_INVALID_SURFACE`; provenance violations use `SESSION_QUERY_INVALID_PROVENANCE`. `listEvents()` only needs surface classification and deliberately does not enforce the trace-specific provenance contract. +`listEvents()` and `traceEvent()` run the same one-pass `dsh-session` surface fold. A loaded log is valid only when surface markers obey event-type eligibility, provenance arrays are nonempty and duplicate-free, references name known earlier events, and each positional replacement names and cites every surface node it removes; every violation fails with `SESSION_QUERY_INVALID_SURFACE`. -`SessionQueryError.code` is a closed union: `SESSION_QUERY_EVENT_NOT_FOUND`, `SESSION_QUERY_INVALID_CONFIG`, `SESSION_QUERY_INVALID_LINEAGE`, `SESSION_QUERY_INVALID_PROVENANCE`, `SESSION_QUERY_INVALID_SURFACE`, `SESSION_QUERY_INVALID_WINDOW`, `SESSION_QUERY_PERSISTENCE_FAILED`, `SESSION_QUERY_SESSION_NOT_FOUND`, and `SESSION_QUERY_SOURCE_CONFLICT`. +`SessionQueryError.code` is a closed union: `SESSION_QUERY_EVENT_NOT_FOUND`, `SESSION_QUERY_INVALID_CONFIG`, `SESSION_QUERY_INVALID_LINEAGE`, `SESSION_QUERY_INVALID_SURFACE`, `SESSION_QUERY_INVALID_WINDOW`, `SESSION_QUERY_PERSISTENCE_FAILED`, `SESSION_QUERY_SESSION_NOT_FOUND`, and `SESSION_QUERY_SOURCE_CONFLICT`. ## Configuration diff --git a/packages/session-query/session-query/src/config.ts b/packages/session-query/session-query/src/config.ts index a6d0ab10fa..4a15366c2c 100644 --- a/packages/session-query/session-query/src/config.ts +++ b/packages/session-query/session-query/src/config.ts @@ -16,7 +16,6 @@ export type SessionQueryErrorCode = | 'SESSION_QUERY_EVENT_NOT_FOUND' | 'SESSION_QUERY_INVALID_CONFIG' | 'SESSION_QUERY_INVALID_LINEAGE' - | 'SESSION_QUERY_INVALID_PROVENANCE' | 'SESSION_QUERY_INVALID_SURFACE' | 'SESSION_QUERY_INVALID_WINDOW' | 'SESSION_QUERY_PERSISTENCE_FAILED' diff --git a/packages/session-query/session-query/src/tracing.ts b/packages/session-query/session-query/src/tracing.ts index 09d3ed65f6..c7b5143b67 100644 --- a/packages/session-query/session-query/src/tracing.ts +++ b/packages/session-query/session-query/src/tracing.ts @@ -1,7 +1,7 @@ /** One-shot session-lineage and event-relationship tracing helpers. */ -import { foldSurface, validateSurfaceMetadata } from '@deepseek-ai/dsh-session' -import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session' +import { foldSurface } from '@deepseek-ai/dsh-session' +import type { SessionEvent, SessionId, SurfaceEventType } from '@deepseek-ai/dsh-session' import { SessionQueryError } from './config.ts' import type { SessionEventRecord, @@ -51,21 +51,6 @@ export function traceEventLog( } const analysis = analyzeEventLog(sessionId, events) - const knownSeqs = new Set() - for (const event of events) { - const violation = validateSurfaceMetadata( - event, - knownSeqs, - analysis.replacedEventSeqs.get(event.seq), - ) - if (violation !== undefined) { - throw new SessionQueryError( - `invalid session provenance: ${violation.message}`, - 'SESSION_QUERY_INVALID_PROVENANCE', - ) - } - knownSeqs.add(event.seq) - } const replacementChain: number[] = [] let replacement = analysis.replacedBy.get(seq) @@ -143,7 +128,9 @@ export function traceLineage( children.push(record) childrenByParent.set(parent, children) } - for (const children of childrenByParent.values()) children.sort(compareSessionsAscending) + for (const children of childrenByParent.values()) { + children.sort((a, b) => a.header.createdAt - b.header.createdAt || a.header.id.localeCompare(b.header.id)) + } const descendants = buildDescendants(childrenByParent, sessionId) const common = { @@ -203,13 +190,8 @@ function analyzeEventLog( } } -function rawEventSources(event: SessionEvent): unknown { - return (event as SessionEvent & { sourceEventSeqs?: unknown }).sourceEventSeqs -} - function eventSources(event: SessionEvent): number[] { - const sources = rawEventSources(event) - return Array.isArray(sources) ? sources as number[] : [] + return (event as SessionEvent).sourceEventSeqs ?? [] } function buildDescendants( @@ -238,10 +220,6 @@ function buildDescendants( return descendants } -function compareSessionsAscending(a: SessionRecord, b: SessionRecord): number { - return a.header.createdAt - b.header.createdAt || a.header.id.localeCompare(b.header.id) -} - function cloneRecord(record: SessionRecord): SessionRecord { return { ...record, header: structuredClone(record.header) } } diff --git a/packages/session-query/session-query/tests/session-query.spec.ts b/packages/session-query/session-query/tests/session-query.spec.ts index 3b50feee45..bb7d01ec73 100644 --- a/packages/session-query/session-query/tests/session-query.spec.ts +++ b/packages/session-query/session-query/tests/session-query.spec.ts @@ -110,7 +110,7 @@ describe('session-query exact reads', () => { session.append( 'assistant/message', { turn: 1, step: 1, content: [{ type: 'text', text: 'replacement' }] }, - { surfaceOp: { op: 'replace', start: first.seq, end: first.seq } }, + { surfaceOp: { op: 'replace', start: first.seq, end: first.seq }, sourceEventSeqs: [first.seq] }, ) expect((await ctx.sessionQuery.listEvents(session.id)).map(record => record.surface)) @@ -227,11 +227,13 @@ describe('session-query exact reads', () => { it('turns malformed surfaces and direct invalid config into typed errors', async () => { const ctx = await liveContext() const session = ctx.sessions.create(SessionId('bad-surface')) - session.append( - 'assistant/message', - { turn: 1, step: 1, content: [] }, - { surfaceOp: { op: 'replace', start: 9, end: 9 } }, - ) + ;(session as unknown as { log: SessionEvent[] }).log.push({ + type: 'assistant/message', + seq: 0, + time: 1, + data: { turn: 1, step: 1, content: [] }, + surfaceOp: { op: 'replace', start: 9, end: 9 }, + }) await expect(ctx.sessionQuery.listEvents(session.id)) .rejects.toThrow(expectCode('SESSION_QUERY_INVALID_SURFACE')) diff --git a/packages/session-query/session-query/tests/tracing.spec.ts b/packages/session-query/session-query/tests/tracing.spec.ts index 0a8b8e6da1..efc32d2216 100644 --- a/packages/session-query/session-query/tests/tracing.spec.ts +++ b/packages/session-query/session-query/tests/tracing.spec.ts @@ -379,7 +379,7 @@ describe('session event tracing', () => { appendEvent(1), { ...appendEvent(2, [0]), surfaceOp: { op: 'replace', start: 1, end: 1 } }, ]], - ] as const)('rejects invalid whole-log provenance: %s', async (_name, rawEvents) => { + ] as const)('rejects an invalid surface log: %s', async (_name, rawEvents) => { const durable = header('invalid-provenance') const events = structuredClone(rawEvents) as unknown as SessionEvent[] TracePersistence.reset([{ meta: durable, events }]) @@ -387,7 +387,7 @@ describe('session event tracing', () => { await ctx.plugin(TracePersistence) await expect(ctx.sessionQuery.traceEvent({ sessionId: durable.id, seq: 0 })) - .rejects.toThrow(expectCode('SESSION_QUERY_INVALID_PROVENANCE')) + .rejects.toThrow(expectCode('SESSION_QUERY_INVALID_SURFACE')) }) it('rejects surfaceOp on a non-surface event as an invalid surface', async () => { @@ -407,15 +407,13 @@ describe('session event tracing', () => { .rejects.toThrow(expectCode('SESSION_QUERY_INVALID_SURFACE')) }) - it('keeps listEvents tolerant of malformed provenance alone', async () => { + it('applies the same surface contract to listEvents', async () => { const durable = header('list-regression') TracePersistence.reset([{ meta: durable, events: [appendEvent(0), appendEvent(1, [0, 0])] }]) const ctx = await queryContext() await ctx.plugin(TracePersistence) - await expect(ctx.sessionQuery.listEvents(durable.id)).resolves.toMatchObject([ - { seq: 0, surface: 'current' }, - { seq: 1, surface: 'current' }, - ]) + await expect(ctx.sessionQuery.listEvents(durable.id)) + .rejects.toThrow(expectCode('SESSION_QUERY_INVALID_SURFACE')) }) }) diff --git a/packages/support/invariants/README.md b/packages/support/invariants/README.md index 2fe2db1f6c..562df0bf3a 100644 --- a/packages/support/invariants/README.md +++ b/packages/support/invariants/README.md @@ -4,7 +4,7 @@ Dev-mode event-contract assertions. This pure-listener plugin checks relationshi **Off in production.** Enable it in tests and the demos, where a contract violation should fail loudly. It costs nothing when not registered, and doubles as executable documentation of the event taxonomy — the assertions *are* the contract. -Session itself owns immutable log storage in every composition: it takes one lossless JSON snapshot of each accepted event, deep-freezes that record, and exposes the log through immutable array snapshots. The invariants plugin checks the cross-record and cross-seam rules that storage immutability cannot express. +Session itself owns immutable, surface-valid log storage in every composition: it takes one lossless JSON snapshot of each candidate, validates the complete surface transition, deep-freezes the accepted record, and exposes the log through immutable array snapshots. The invariants plugin checks the remaining cross-record and cross-seam rules that Session does not own. Session-log assertions run during Cordis `internal/dispatch`, while `Session.append()` is resolving the `session/event` callback snapshot but before it pushes the candidate into the log. A valid transition is staged by exact event identity and applied to the live trace only when that same committed event reaches the plugin's contained post-commit listener. A later internal dispatch check can therefore veto without advancing either the log or the invariant trace, while ordinary `session/event` observer failures remain observe-only. @@ -28,7 +28,6 @@ await ctx.plugin(Invariants) Session log (per session): - **`seq` strictly increases** — the spine of replay equivalence. -- **surface metadata is valid** — `surfaceOp` and `sourceEventSeqs` use the shared `dsh-session` checker for type eligibility, structural shape, nonempty unique earlier references, and complete replacement coverage. - **turns pair and nest** — `turn/start` opens a turn, `turn/end` closes the matching one; no overlapping turns. - **steps nest in turns** — `step/start` opens a step in the open turn; `step/end` closes the matching step. - **chunks belong to an open step** — `step/start` precedes its `assistant/chunk`s. diff --git a/packages/support/invariants/src/index.ts b/packages/support/invariants/src/index.ts index b2fa15080f..db25c42b89 100644 --- a/packages/support/invariants/src/index.ts +++ b/packages/support/invariants/src/index.ts @@ -6,13 +6,12 @@ * `session/event`, `agent/status`, and the scoped dispatch and request seams. * It is **off in production**: enable it in tests and demos, where a contract * violation should be a loud failure rather than a subtle one. It doubles as - * executable documentation of the event taxonomy: these assertions and the - * shared session validators they invoke are the contract. + * executable documentation of the relational event taxonomy. * - * Session owns immutable log storage: it snapshots and deep-freezes every - * accepted event at the source. This plugin checks relationships that one - * event's types and immutability cannot express, including turn/step nesting, - * scoped dispatch, status transitions, and request reconstructability. + * Session owns immutable, surface-valid log storage: it validates, snapshots, + * and deep-freezes every accepted event at the source. This plugin checks the + * remaining relationships that acceptance cannot express, including turn/step + * nesting, scoped dispatch, status transitions, and request reconstructability. * * @module @deepseek-ai/dsh-invariants */ @@ -26,9 +25,8 @@ import { Session, SessionId, foldRequestHeader, - validateSurfaceMetadata, } from '@deepseek-ai/dsh-session' -import type { SessionEvent, SurfaceEventType } from '@deepseek-ai/dsh-session' +import type { SessionEvent } from '@deepseek-ai/dsh-session' export const name = 'invariants' export const inject = ['sessions'] @@ -62,15 +60,6 @@ interface SessionTrace { * `step/end` — a result must arrive in the same step as its call. */ pendingCalls: Set - /** Every seq seen so far — validates `sourceEventSeqs` references. */ - knownSeqs: Set - /** - * The seqs currently on the surface linked list, in linked-list order - * (head to tail). A replace reorders this relative to seq order (the new - * node takes the replaced range's position), so range validation is - * positional, not by seq comparison. - */ - surface: number[] } /** One accepted event's deferred mutation of a live session trace. */ @@ -82,12 +71,6 @@ interface SessionTraceTransition { | { kind: 'none' } | { kind: 'add' | 'delete'; callId: CallId } | { kind: 'clear' } - /** The event's mutation of the derived surface order. */ - surface: - | { kind: 'none' | 'append' } - | { kind: 'replace'; start: number; count: number } - /** The committed event sequence to add to the known-sequence set. */ - seq: number } /** Event payload prefix for scoped seams whose first argument names its agent. */ @@ -122,50 +105,6 @@ function validateEvent(trace: SessionTrace, event: SessionEvent): SessionTraceTr let nextTurn = trace.nextTurn let nextStep = trace.nextStep let pendingCalls: SessionTraceTransition['pendingCalls'] = { kind: 'none' } - let surface: SessionTraceTransition['surface'] = { kind: 'none' } - - // --- Surface invariants --- - // Cast to surface-eligible event type so we can access surfaceOp and - // sourceEventSeqs (optional on SessionEvent, mandatory on SurfaceEvent). - // SurfaceEvent's mandatory surfaceOp is too strict here — we need to - // CHECK whether surface metadata is present, not assume it. - const se = event as SessionEvent - const metadataViolation = validateSurfaceMetadata(event) - if (metadataViolation !== undefined) throw new InvariantError(metadataViolation.message) - - // Fold this event into the tracked surface linked list, validating the - // replace contract as we go. `append` adds a tail node; `replace` shadows a - // positional range — every shadowed node must appear in sourceEventSeqs. - let shadowed: number[] | undefined - if (se.surfaceOp !== undefined) { - if (se.surfaceOp === 'append') { - surface = { kind: 'append' } - } else { - const { start, end } = se.surfaceOp - const startIdx = trace.surface.indexOf(start) - if (startIdx === -1) { - throw new InvariantError(`surface replace: start seq ${start} is not on the surface`) - } - const endIdx = trace.surface.indexOf(end) - if (endIdx === -1) { - throw new InvariantError(`surface replace: end seq ${end} is not on the surface`) - } - if (startIdx > endIdx) { - throw new InvariantError(`surface replace: start seq ${start} (pos ${startIdx}) is after end seq ${end} (pos ${endIdx}) on the surface`) - } - shadowed = trace.surface.slice(startIdx, endIdx + 1) - surface = { kind: 'replace', start: startIdx, count: shadowed.length } - } - } - - const provenanceViolation = validateSurfaceMetadata( - event, - trace.knownSeqs, - shadowed, - ) - if (provenanceViolation !== undefined) { - throw new InvariantError(provenanceViolation.message) - } // Boundary/step-scoped events have explicit cases; every OTHER event type — // including plugin-added (merge-extensible) SessionEventMap keys — is caught @@ -265,8 +204,6 @@ function validateEvent(trace: SessionTrace, event: SessionEvent): SessionTraceTr return { scalars: { lastSeq: event.seq, openTurn, openStep, nextTurn, nextStep }, pendingCalls, - surface, - seq: event.seq, } } @@ -289,20 +226,6 @@ function applyTransition(trace: SessionTrace, transition: SessionTraceTransition default: assertNever(transition.pendingCalls, 'session trace pending-call transition') } - switch (transition.surface.kind) { - case 'none': - break - case 'append': - trace.surface.push(transition.seq) - break - case 'replace': - trace.surface.splice(transition.surface.start, transition.surface.count, transition.seq) - break - /* v8 ignore next -- validateEvent produces this closed transition union */ - default: - assertNever(transition.surface, 'session trace surface transition') - } - trace.knownSeqs.add(transition.seq) } /** Validate and apply one event while rebuilding an already-committed log. */ @@ -351,8 +274,6 @@ export function apply(ctx: Context): void { nextTurn: 1, nextStep: 1, pendingCalls: new Set(), - knownSeqs: new Set(), - surface: [], }) /** Build (or rebuild) a session's trace by replaying its whole log. */ diff --git a/packages/support/invariants/tests/invariants.spec.ts b/packages/support/invariants/tests/invariants.spec.ts index a1e824524b..d5f43d1568 100644 --- a/packages/support/invariants/tests/invariants.spec.ts +++ b/packages/support/invariants/tests/invariants.spec.ts @@ -466,7 +466,7 @@ describe('HMR safety', () => { }) }) -describe('surface invariants', () => { +describe('surface contract under the invariants composition', () => { it('accepts well-formed surface metadata', async () => { const { ctx } = await setup() const session = ctx.sessions.create() @@ -495,7 +495,7 @@ describe('surface invariants', () => { session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) expect(() => { session.append('assistant/message', { turn: 1, step: 1, content: [] }, { surfaceOp: 'append', sourceEventSeqs: [] }) - }).toThrow(InvariantError) + }).toThrow(/must not be empty/) }) it('rejects duplicate sourceEventSeqs', async () => { @@ -542,15 +542,15 @@ describe('surface invariants', () => { it('rejects sourceEventSeqs referencing unknown seq (gap in event log)', async () => { // The unknown-seq check fires when a ref passes the "earlier" test but is - // not in knownSeqs — only possible with a gap in seqs. We create a gap by + // not in the folded log — only possible with a gap in seqs. We create a gap by // directly manipulating the private log array to skip a seq. const { ctx } = await setup() const session = ctx.sessions.create() session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) session.append('step/start', { turn: 1, step: 1 }) // Push a fake event at seq 3 into the internal log, creating a gap at seq 2. - // The invariants plugin replays session.events on every append, so it sees - // this gap during trace reconstruction. + // The canonical surface validator folds the committed delta before checking + // the next append, so it sees this gap. ;(session as unknown as { log: unknown[] }).log.push({ type: 'assistant/chunk', seq: 3, @@ -575,7 +575,7 @@ describe('surface invariants', () => { // Reversed range: start seq 3 is at a later surface position than end seq 2. expect(() => { session.append('assistant/message', { turn: 1, step: 1, content: [] }, { surfaceOp: { op: 'replace', start: 3, end: 2 }, sourceEventSeqs: [2, 3] }) - }).toThrow(/is after end seq 2 .* on the surface/) + }).toThrow(/is after end seq 2/) }) it('rejects a replace whose sourceEventSeqs omits a shadowed surface node', async () => { @@ -612,7 +612,7 @@ describe('surface invariants', () => { // seq 1 (step/start) is a real earlier event but never entered the surface. expect(() => { session.append('assistant/message', { turn: 1, step: 1, content: [] }, { surfaceOp: { op: 'replace', start: 1, end: 2 }, sourceEventSeqs: [1, 2] }) - }).toThrow(/start seq 1 is not on the surface/) + }).toThrow(/start seq 1 not found in surface/) }) it('rejects a replace naming an end seq that is not on the surface', async () => { @@ -624,7 +624,7 @@ describe('surface invariants', () => { // start (2) is on the surface but end (99) never entered it. expect(() => { session.append('assistant/message', { turn: 1, step: 1, content: [] }, { surfaceOp: { op: 'replace', start: 2, end: 99 }, sourceEventSeqs: [2] }) - }).toThrow(/end seq 99 is not on the surface/) + }).toThrow(/end seq 99 not found in surface/) }) it('rejects a replace whose range is reversed in surface position after a prior replace reordered it', async () => { @@ -641,7 +641,7 @@ describe('surface invariants', () => { // reversed positionally (3 is at pos 1, 4 is at pos 0). expect(() => { session.append('assistant/message', { turn: 1, step: 1, content: [] }, { surfaceOp: { op: 'replace', start: 3, end: 4 }, sourceEventSeqs: [3, 4] }) // seq 5 - }).toThrow(/is after end seq 4 .* on the surface/) + }).toThrow(/is after end seq 4/) }) it('accepts a replace whose start seq exceeds its end seq when the surface position order is valid', async () => { @@ -685,25 +685,6 @@ describe('surface invariants', () => { expect(() => ctx.sessions.create(undefined, { seed: badSeed })).toThrow(/must include every shadowed surface node; missing 3/) }) - it('rejects sourceEventSeqs on a non-surface event', async () => { - const { ctx } = await setup() - const session = ctx.sessions.create() - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) - // Session rejects this at its own acceptance boundary. Emit a hand-built - // record to cover the listener's defensive check for alternate producers. - const event = { type: 'turn/end', seq: 1, time: 1, data: { turn: 1, reason: { kind: 'completed' } }, sourceEventSeqs: [0] } - expect(() => { ctx.emit(scopeTarget(session, undefined), 'session/event', session, event as never) }) - .toThrow(/cannot carry sourceEventSeqs/) - }) - - it('rejects surfaceOp on a non-surface event', async () => { - const { ctx } = await setup() - const session = ctx.sessions.create() - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) - const event = { type: 'turn/end', seq: 1, time: 1, data: { turn: 1, reason: { kind: 'completed' } }, surfaceOp: 'append' } - expect(() => { ctx.emit(scopeTarget(session, undefined), 'session/event', session, event as never) }) - .toThrow(/cannot carry surfaceOp/) - }) }) describe('request-reconstruction cross-check (llm/stream)', () => { From dc60fe957272a439fa6ac920e1e42cb638f32d6e Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Tue, 14 Jul 2026 14:23:02 +0800 Subject: [PATCH 08/12] Avoid retaining processed session seqs --- .../2026-07-13-session-query-tracing.md | 2 +- packages/core/session/README.md | 2 +- packages/core/session/src/index.ts | 2 +- packages/core/session/src/surface.ts | 38 ++++++++++--------- packages/core/session/tests/surface.spec.ts | 2 +- .../session-query/session-query/README.md | 2 +- .../invariants/tests/invariants.spec.ts | 28 +------------- 7 files changed, 27 insertions(+), 49 deletions(-) diff --git a/docs/rfc/implemented/feature/2026-07-13-session-query-tracing.md b/docs/rfc/implemented/feature/2026-07-13-session-query-tracing.md index 9ebb8ee961..f16f543ac5 100644 --- a/docs/rfc/implemented/feature/2026-07-13-session-query-tracing.md +++ b/docs/rfc/implemented/feature/2026-07-13-session-query-tracing.md @@ -16,7 +16,7 @@ Session relationships are encoded across immutable headers, positional surface o ## Validation boundary -Event tracing checks target existence before surface analysis. Both event listing and tracing then use `dsh-session`'s one-pass surface fold, which accepts or rejects the loaded log as a whole: surface markers obey event-type eligibility, provenance belongs only to surface event types, present arrays are nonempty and duplicate-free, every source is a known earlier seq, and every positional replacement names and cites all surface nodes it removes. Every contract failure uses `SESSION_QUERY_INVALID_SURFACE`; there is no weaker classification-only surface standard. +Event tracing checks target existence before surface analysis. Both event listing and tracing then use `dsh-session`'s one-pass surface fold, which accepts or rejects the loaded log as a whole: event seqs are zero-based and contiguous, surface markers obey event-type eligibility, provenance belongs only to surface event types, present arrays are nonempty and duplicate-free, every source is an earlier seq, and every positional replacement names and cites all surface nodes it removes. Every contract failure uses `SESSION_QUERY_INVALID_SURFACE`; there is no weaker classification-only surface standard. All returned records and arrays are detached. A known live event trace never consults persistence; persisted event traces preserve the exact-read list/load consistency check. Session lineage is necessarily a cross-corpus operation and therefore preserves cross-corpus persistence failure semantics. diff --git a/packages/core/session/README.md b/packages/core/session/README.md index 7632212621..18eb6ebd82 100644 --- a/packages/core/session/README.md +++ b/packages/core/session/README.md @@ -49,7 +49,7 @@ Durable values need one accepted representation, not a check followed by a secon - `SurfaceOp` — how a surface node entered the linked list: `'append'` (normal tail append) or `{ op: 'replace', start, end }` (replace nodes from `start` through `end` inclusive — both must be valid surface node seqs; `start === end` replaces a single node). Used by compaction to shadow old nodes without deleting them. - `SurfaceIntent` — `{ surfaceOp: SurfaceOp; sourceEventSeqs?: number[] }`, the required third parameter to `session.append()` for surface-eligible types. - `SurfaceNode` — `{ seq: number; prev: number | null; next: number | null }`, one node in the surface linked list. -- `foldSurface(events)` — replay the one canonical surface contract into detached current nodes and actual replacement ranges. The same pass rejects misplaced or malformed metadata, empty or duplicate provenance, unknown or non-earlier sources, invalid positional ranges, and replacements that fail to cite every shadowed surface node; `SurfaceManager` shares the atomic transition while retaining its incremental cache. +- `foldSurface(events)` — replay the one canonical surface contract into detached current nodes and actual replacement ranges. The same pass rejects non-contiguous event seqs, misplaced or malformed metadata, empty or duplicate provenance, non-earlier sources, invalid positional ranges, and replacements that fail to cite every shadowed surface node; `SurfaceManager` shares the atomic transition while retaining its incremental cache. - `isSurfaceEvent(event)` / `isSurfaceEligibleType(type)` — the first narrows a `SessionEvent` to a fully-formed surface node (type is surface-eligible AND `surfaceOp` present); the second is the type-only check (is this one of the five `SurfaceEventType` values?), used to detect a surface-eligible event MISSING its marker — e.g. when validating a seed/load log. ### Request-header reconstruction (`request-header.ts`) diff --git a/packages/core/session/src/index.ts b/packages/core/session/src/index.ts index fc3a7fcd29..3c881882ba 100644 --- a/packages/core/session/src/index.ts +++ b/packages/core/session/src/index.ts @@ -328,7 +328,7 @@ export class Session { * (BigInt, function, symbol, undefined, negative zero, non-finite number, * circular reference, sparse array, or an exotic object such as * Map/Set/Date/class instance), or when the candidate violates the - * canonical surface contract (marker shape and eligibility, unique known + * canonical surface contract (marker shape and eligibility, unique * earlier provenance, positional replacement validity, and complete * shadowed-node coverage). One recursive pass reads, validates, and * copies each nested value once, so a stateful getter cannot supply one value diff --git a/packages/core/session/src/surface.ts b/packages/core/session/src/surface.ts index 7e1da76832..0d9e93e961 100644 --- a/packages/core/session/src/surface.ts +++ b/packages/core/session/src/surface.ts @@ -85,7 +85,6 @@ export interface SurfaceFoldResult { interface SurfaceFoldState { nodes: SurfaceNode[] nodeBySeq: Map - knownSeqs: Set replaceGeneration: number } @@ -106,7 +105,6 @@ function createFoldState(replaceGeneration = 0): SurfaceFoldState { return { nodes: [], nodeBySeq: new Map(), - knownSeqs: new Set(), replaceGeneration, } } @@ -163,7 +161,6 @@ function surfaceEventOf(event: SessionEvent): SurfaceEvent | undefined { /** Validate provenance against prior log entries and the replacement range. */ function assertProvenance( event: SurfaceEvent, - knownSeqs: ReadonlySet, shadowedSeqs: readonly number[], ): void { const sources = event.sourceEventSeqs @@ -178,9 +175,6 @@ function assertProvenance( if (source >= event.seq) { throw new Error(`sourceEventSeqs must reference earlier events: ${source} >= current seq ${event.seq}`) } - if (!knownSeqs.has(source)) { - throw new Error(`sourceEventSeqs references unknown seq ${source}`) - } } const missing = shadowedSeqs.filter(seq => !sourceSet.has(seq)) if (missing.length > 0) { @@ -213,16 +207,23 @@ function replacementRange( } } -/** Validate one event and prepare its atomic fold transition. */ -function planSurfaceEvent(state: SurfaceFoldState, event: SessionEvent): SurfacePlan | undefined { +/** Validate one event at its replay boundary and prepare its atomic fold transition. */ +function planSurfaceEvent( + state: SurfaceFoldState, + event: SessionEvent, + expectedSeq: number, +): SurfacePlan | undefined { + if (event.seq !== expectedSeq) { + throw new Error(`session event seq ${event.seq} is not contiguous; expected ${expectedSeq}`) + } const surfaceEvent = surfaceEventOf(event) if (surfaceEvent === undefined) return if (surfaceEvent.surfaceOp === 'append') { - assertProvenance(surfaceEvent, state.knownSeqs, []) + assertProvenance(surfaceEvent, []) return { kind: 'append', seq: event.seq } } const range = replacementRange(state, surfaceEvent.surfaceOp) - assertProvenance(surfaceEvent, state.knownSeqs, range.shadowedSeqs) + assertProvenance(surfaceEvent, range.shadowedSeqs) return { kind: 'replace', seq: event.seq, @@ -257,8 +258,9 @@ function replaceSurface(state: SurfaceFoldState, plan: SurfaceReplacePlan): void function applySurfaceEvent( state: SurfaceFoldState, event: SessionEvent, + expectedSeq: number, ): SurfaceFoldReplacement | undefined { - const plan = planSurfaceEvent(state, event) + const plan = planSurfaceEvent(state, event, expectedSeq) if (plan?.kind === 'append') { const tail = state.nodes.at(-1) const node: SurfaceNode = { seq: plan.seq, prev: tail?.seq ?? null, next: null } @@ -268,7 +270,6 @@ function applySurfaceEvent( } else if (plan?.kind === 'replace') { replaceSurface(state, plan) } - state.knownSeqs.add(event.seq) if (plan?.kind !== 'replace') return return { seq: plan.seq, @@ -287,14 +288,15 @@ function applySurfaceEvent( * @param events - session events in contiguous seq order. * @returns the current surface and every positional replacement. * @throws when any event violates the unified surface contract: metadata must - * be well shaped and type-eligible, provenance must name unique known earlier - * events, and a positional replacement must name and cite its complete range. + * be well shaped and type-eligible, event seqs must be contiguous, provenance + * must name unique earlier events, and a positional replacement must name and + * cite its complete range. */ export function foldSurface(events: readonly SessionEvent[]): SurfaceFoldResult { const state = createFoldState() const replacements: SurfaceFoldReplacement[] = [] - for (const event of events) { - const replacement = applySurfaceEvent(state, event) + for (const [index, event] of events.entries()) { + const replacement = applySurfaceEvent(state, event, index) if (replacement !== undefined) replacements.push(replacement) } return { @@ -326,7 +328,7 @@ export class SurfaceManager { */ validateNext(event: SessionEvent): void { if (this._lastProcessedSeq < this.log.length - 1) this._processDelta() - planSurfaceEvent(this._state, event) + planSurfaceEvent(this._state, event, this.log.length) } /** @@ -370,7 +372,7 @@ export class SurfaceManager { // Index is bounded by i < this.log.length — never undefined. // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const event = this.log[i]! - applySurfaceEvent(this._state, event) + applySurfaceEvent(this._state, event, i) this._lastProcessedSeq = i } } diff --git a/packages/core/session/tests/surface.spec.ts b/packages/core/session/tests/surface.spec.ts index e1f948f2b1..2fab5075aa 100644 --- a/packages/core/session/tests/surface.spec.ts +++ b/packages/core/session/tests/surface.spec.ts @@ -62,7 +62,7 @@ describe('foldSurface provenance', () => { ['a fractional number', [provenanceEvent(0, [0.5])], /non-negative safe integers/], ['a negative number', [provenanceEvent(0, [-1])], /non-negative safe integers/], ['a self reference', [provenanceEvent(0, [0])], /must reference earlier events/], - ['an unknown earlier seq', [provenanceEvent(0, undefined), provenanceEvent(2, [1])], /references unknown seq 1/], + ['a non-contiguous event seq', [provenanceEvent(0, undefined), provenanceEvent(2, [1])], /seq 2 is not contiguous; expected 1/], ['incomplete replacement coverage', [ provenanceEvent(0, undefined), provenanceEvent(1, undefined), diff --git a/packages/session-query/session-query/README.md b/packages/session-query/session-query/README.md index 4f9f8122b4..2be712bd9b 100644 --- a/packages/session-query/session-query/README.md +++ b/packages/session-query/session-query/README.md @@ -14,7 +14,7 @@ This is trusted context-wide infrastructure. It performs no caller authorization Persistence is optional and may mount or unmount dynamically. Cross-corpus listing and lineage tracing fail with `SESSION_QUERY_PERSISTENCE_FAILED` while mounted persistence is unreadable. An event read or trace targeting a known live session does not consult persistence, so durable backend health cannot make current in-memory history unreadable. Persisted event operations list before loading and reject a metadata mismatch rather than combining inconsistent observations. -`listEvents()` and `traceEvent()` run the same one-pass `dsh-session` surface fold. A loaded log is valid only when surface markers obey event-type eligibility, provenance arrays are nonempty and duplicate-free, references name known earlier events, and each positional replacement names and cites every surface node it removes; every violation fails with `SESSION_QUERY_INVALID_SURFACE`. +`listEvents()` and `traceEvent()` run the same one-pass `dsh-session` surface fold. A loaded log is valid only when event seqs are zero-based and contiguous, surface markers obey event-type eligibility, provenance arrays are nonempty and duplicate-free, references name earlier events, and each positional replacement names and cites every surface node it removes; every violation fails with `SESSION_QUERY_INVALID_SURFACE`. `SessionQueryError.code` is a closed union: `SESSION_QUERY_EVENT_NOT_FOUND`, `SESSION_QUERY_INVALID_CONFIG`, `SESSION_QUERY_INVALID_LINEAGE`, `SESSION_QUERY_INVALID_SURFACE`, `SESSION_QUERY_INVALID_WINDOW`, `SESSION_QUERY_PERSISTENCE_FAILED`, `SESSION_QUERY_SESSION_NOT_FOUND`, and `SESSION_QUERY_SOURCE_CONFLICT`. diff --git a/packages/support/invariants/tests/invariants.spec.ts b/packages/support/invariants/tests/invariants.spec.ts index d5f43d1568..7c83f4571d 100644 --- a/packages/support/invariants/tests/invariants.spec.ts +++ b/packages/support/invariants/tests/invariants.spec.ts @@ -520,7 +520,8 @@ describe('surface contract under the invariants composition', () => { }) it('accepts sourceEventSeqs referencing a valid earlier event', async () => { - // Positive test: ref < current seq and ref is in knownSeqs → passes. + // Session seqs are contiguous, so every non-negative ref below the current + // seq necessarily names an existing earlier event. const { ctx } = await setup() const session = ctx.sessions.create() session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) @@ -540,31 +541,6 @@ describe('surface contract under the invariants composition', () => { }).toThrow(/must reference earlier/) }) - it('rejects sourceEventSeqs referencing unknown seq (gap in event log)', async () => { - // The unknown-seq check fires when a ref passes the "earlier" test but is - // not in the folded log — only possible with a gap in seqs. We create a gap by - // directly manipulating the private log array to skip a seq. - const { ctx } = await setup() - const session = ctx.sessions.create() - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) - session.append('step/start', { turn: 1, step: 1 }) - // Push a fake event at seq 3 into the internal log, creating a gap at seq 2. - // The canonical surface validator folds the committed delta before checking - // the next append, so it sees this gap. - ;(session as unknown as { log: unknown[] }).log.push({ - type: 'assistant/chunk', - seq: 3, - time: Date.now(), - data: { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'x' } }, - }) - // Now the log has seqs 0, 1, 3 (gap at 2). Append at what session believes - // is seq 3 (log.length). Reference seq 2: passes earlier (2 < 3) but not - // in knownSeqs ({0, 1, 3} — gap at 2). - expect(() => { - session.append('assistant/message', { turn: 1, step: 1, content: [] }, { surfaceOp: 'append', sourceEventSeqs: [2] }) - }).toThrow(/unknown seq 2/) - }) - it('rejects a replace whose start is positioned after its end on the surface', async () => { const { ctx } = await setup() const session = ctx.sessions.create() From c5ac667e863050c91a5308f55d62cb8eeabf05fd Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Tue, 14 Jul 2026 17:20:57 +0800 Subject: [PATCH 09/12] refactor(session-query): simplify tracing helpers --- .../session-query/session-query/src/index.ts | 8 ++++---- .../session-query/src/tracing.ts | 19 ++++++++----------- 2 files changed, 12 insertions(+), 15 deletions(-) diff --git a/packages/session-query/session-query/src/index.ts b/packages/session-query/session-query/src/index.ts index e5659c554a..bd35b51442 100644 --- a/packages/session-query/session-query/src/index.ts +++ b/packages/session-query/session-query/src/index.ts @@ -22,7 +22,7 @@ import { type Config, } from './config.ts' import { SessionCorpus } from './corpus.ts' -import { eventRecords, traceEventLog, traceLineage } from './tracing.ts' +import * as tracing from './tracing.ts' export type * from './types.ts' export type { Config, SessionQueryErrorCode } from './config.ts' @@ -71,7 +71,7 @@ export class SessionQueryService extends Service { */ async listEvents(sessionId: SessionId): Promise { const loaded = await this._corpus.load(sessionId) - return eventRecords(sessionId, loaded.events) + return tracing.eventRecords(sessionId, loaded.events) } /** @@ -82,7 +82,7 @@ export class SessionQueryService extends Service { */ async traceSession(sessionId: SessionId): Promise { const records = await this._corpus.listSessions() - return traceLineage(records, sessionId) + return tracing.traceSession(records, sessionId) } /** @@ -93,7 +93,7 @@ export class SessionQueryService extends Service { */ async traceEvent(request: SessionEventTraceRequest): Promise { const loaded = await this._corpus.load(request.sessionId) - return traceEventLog(request.sessionId, loaded.events, request.seq) + return tracing.traceEvent(request.sessionId, loaded.events, request.seq) } /** diff --git a/packages/session-query/session-query/src/tracing.ts b/packages/session-query/session-query/src/tracing.ts index c7b5143b67..2f422d6c26 100644 --- a/packages/session-query/session-query/src/tracing.ts +++ b/packages/session-query/session-query/src/tracing.ts @@ -37,7 +37,7 @@ export function eventRecords( * @param seq - target event seq. * @returns direct surface and provenance relationships. */ -export function traceEventLog( +export function traceEvent( sessionId: SessionId, events: readonly SessionEvent[], seq: number, @@ -59,7 +59,6 @@ export function traceEventLog( replacement = analysis.replacedBy.get(replacement) } - const sourceEventSeqs = eventSources(target) const derivedEventSeqs: number[] = [] for (const event of events) { if (event.seq <= seq) continue @@ -71,11 +70,11 @@ export function traceEventLog( const targetRecord = analysis.records[seq]! const replacedBy = analysis.replacedBy.get(seq) return { - target: { ...targetRecord }, + target: targetRecord, ...replacedBy === undefined ? {} : { replacedBy }, replacementChain, - replacedEventSeqs: [...(analysis.replacedEventSeqs.get(seq) ?? [])], - sourceEventSeqs: [...sourceEventSeqs], + replacedEventSeqs: analysis.replacedEventSeqs.get(seq) ?? [], + sourceEventSeqs: [...eventSources(target)], derivedEventSeqs, } } @@ -86,7 +85,7 @@ export function traceEventLog( * @param sessionId - target session id. * @returns complete or explicitly partial lineage. */ -export function traceLineage( +export function traceSession( records: readonly SessionRecord[], sessionId: SessionId, ): SessionLineageTrace { @@ -164,14 +163,12 @@ function analyzeEventLog( ) } const current = new Set(folded.nodes.map(node => node.seq)) - const shadowed = new Set() const replacedBy = new Map() const replacedEventSeqs = new Map() for (const replacement of folded.replacements) { - const removed = [...replacement.shadowedSeqs] + const removed = replacement.shadowedSeqs replacedEventSeqs.set(replacement.seq, removed) for (const removedSeq of removed) { - shadowed.add(removedSeq) replacedBy.set(removedSeq, replacement.seq) } } @@ -183,14 +180,14 @@ function analyzeEventLog( time: event.time, surface: current.has(event.seq) ? 'current' - : shadowed.has(event.seq) ? 'shadowed' : 'log-only', + : replacedBy.has(event.seq) ? 'shadowed' : 'log-only', })), replacedBy, replacedEventSeqs, } } -function eventSources(event: SessionEvent): number[] { +function eventSources(event: SessionEvent): readonly number[] { return (event as SessionEvent).sourceEventSeqs ?? [] } From 1222d07da965b40be77861c8455c0efc09129eeb Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Wed, 15 Jul 2026 11:43:58 +0800 Subject: [PATCH 10/12] Document host sandbox retry behavior --- AGENTS.md | 4 ++++ scripts/doc-budgets.manifest.json | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 22c1f47aa8..d4bfca2dab 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -58,6 +58,10 @@ pnpm run demo:cordis # self-referential demo: the agent modifies its own runt pnpm run demo:acp # ACP server agent (needs DEEPSEEK_API_KEY) ``` +### Host sandbox failures + +When required `gh`, `pnpm`, build, test, or generator commands fail because the agent sandbox blocks credentials, network, IPC, file watching, or nested `sandbox-exec`, retry unchanged with the narrowest host escalation before diagnosing authentication or project failure. Require sandbox evidence; never bypass genuine test failures or the product sandbox under test. + ### Run the CI gates locally before marking a PR ready Run narrow checks during implementation and this CI-equivalent sequence before marking a PR ready. Fresh worktrees need `pnpm run build` before publint and NodeNext inspect `lib/`: diff --git a/scripts/doc-budgets.manifest.json b/scripts/doc-budgets.manifest.json index 80957af255..87993729ba 100644 --- a/scripts/doc-budgets.manifest.json +++ b/scripts/doc-budgets.manifest.json @@ -1,5 +1,5 @@ { - "AGENTS.md": 1370, + "AGENTS.md": 1500, "docs/AGENTS.md": 1100, "docs/architecture.md": 1790, "docs/cordis-primer.md": 600, From a6c8775f121abb846f1dab0e3b5c09a40f45632f Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Thu, 16 Jul 2026 11:08:59 +0800 Subject: [PATCH 11/12] fix(session): reject sparse provenance arrays --- packages/core/session/src/surface.ts | 79 ++++++++++--------- packages/core/session/tests/surface.spec.ts | 1 + .../session-query/tests/tracing.spec.ts | 3 + 3 files changed, 46 insertions(+), 37 deletions(-) diff --git a/packages/core/session/src/surface.ts b/packages/core/session/src/surface.ts index d585ab9798..1662d2d80d 100644 --- a/packages/core/session/src/surface.ts +++ b/packages/core/session/src/surface.ts @@ -123,8 +123,8 @@ function isReplaceOp(value: object): value is Extract= event.seq) { - throw new Error(`sourceEventSeqs must reference earlier events: ${source} >= current seq ${event.seq}`) + const raw = (event as SessionEvent & { sourceEventSeqs?: unknown }).sourceEventSeqs + const sources = new Set() + if (raw !== undefined) { + if (!Array.isArray(raw)) { + throw new Error(`sourceEventSeqs on event at seq ${event.seq} must be an array when present`) + } + if (raw.length === 0) { + throw new Error('sourceEventSeqs must not be empty when present') + } + let nonEarlierSource: number | undefined + for (const source of raw) { + if (!isEventSeq(source)) { + throw new Error(`session event "${event.type}" sourceEventSeqs must densely contain non-negative safe integers`) + } + sources.add(source) + if (nonEarlierSource === undefined && source >= event.seq) nonEarlierSource = source + } + if (sources.size !== raw.length) { + throw new Error('sourceEventSeqs must not contain duplicates') + } + if (nonEarlierSource !== undefined) { + throw new Error(`sourceEventSeqs must reference earlier events: ${nonEarlierSource} >= current seq ${event.seq}`) } } - const missing = shadowedSeqs.filter(seq => !sourceSet.has(seq)) + const missing = shadowedSeqs.filter(seq => !sources.has(seq)) if (missing.length > 0) { throw new Error(`surface replace: sourceEventSeqs must include every shadowed surface node; missing ${missing.join(', ')}`) } @@ -213,19 +218,19 @@ function planSurfaceEvent( if (event.seq !== expectedSeq) { throw new Error(`session event seq ${event.seq} is not contiguous; expected ${expectedSeq}`) } - const surfaceEvent = surfaceEventOf(event) - if (surfaceEvent === undefined) return - if (surfaceEvent.surfaceOp === 'append') { - assertProvenance(surfaceEvent, []) + const surfaceOp = surfaceOpOf(event) + if (surfaceOp === undefined) return + if (surfaceOp === 'append') { + assertProvenance(event, []) return { kind: 'append', seq: event.seq } } - const range = replacementRange(state, surfaceEvent.surfaceOp) - assertProvenance(surfaceEvent, range.shadowedSeqs) + const range = replacementRange(state, surfaceOp) + assertProvenance(event, range.shadowedSeqs) return { kind: 'replace', seq: event.seq, - start: surfaceEvent.surfaceOp.start, - end: surfaceEvent.surfaceOp.end, + start: surfaceOp.start, + end: surfaceOp.end, ...range, } } diff --git a/packages/core/session/tests/surface.spec.ts b/packages/core/session/tests/surface.spec.ts index 87012b668d..1615e12f4b 100644 --- a/packages/core/session/tests/surface.spec.ts +++ b/packages/core/session/tests/surface.spec.ts @@ -58,6 +58,7 @@ describe('foldSurface provenance', () => { ['a non-array', [{ ...provenanceEvent(0, undefined), sourceEventSeqs: 'invalid' }], /must be an array/], ['an empty array', [provenanceEvent(0, [])], /must not be empty/], ['duplicates', [provenanceEvent(0, undefined), provenanceEvent(1, [0, 0])], /must not contain duplicates/], + ['a sparse array', [provenanceEvent(0, Array(1))], /densely contain/], ['a non-number', [{ ...provenanceEvent(0, undefined), sourceEventSeqs: ['0'] }], /non-negative safe integers/], ['a fractional number', [provenanceEvent(0, [0.5])], /non-negative safe integers/], ['a negative number', [provenanceEvent(0, [-1])], /non-negative safe integers/], diff --git a/packages/session-query/session-query/tests/tracing.spec.ts b/packages/session-query/session-query/tests/tracing.spec.ts index efc32d2216..eb22a9f95c 100644 --- a/packages/session-query/session-query/tests/tracing.spec.ts +++ b/packages/session-query/session-query/tests/tracing.spec.ts @@ -358,6 +358,9 @@ describe('session event tracing', () => { ['empty sources', [ appendEvent(0, []), ]], + ['sparse sources', [ + appendEvent(0, Array(1)), + ]], ['duplicate sources', [ appendEvent(0), appendEvent(1, [0, 0]), From 5266905455665aa0f95e635dd932b4fdf9b8f72e Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Fri, 17 Jul 2026 18:43:21 +0800 Subject: [PATCH 12/12] test(workspace-context): cite compacted surface source --- .../workspace-context/tests/workspace-context.spec.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/context/workspace-context/tests/workspace-context.spec.ts b/packages/context/workspace-context/tests/workspace-context.spec.ts index 82f262f34b..0132a64eb8 100644 --- a/packages/context/workspace-context/tests/workspace-context.spec.ts +++ b/packages/context/workspace-context/tests/workspace-context.spec.ts @@ -2182,7 +2182,10 @@ describe('dynamic nested workspace context injection', () => { agent.session.append('user/message', { content: [{ type: 'text', text: 'compacted summary' }], source: { kind: 'plugin', plugin: 'compact' }, - }, { surfaceOp: { op: 'replace', start: contextSeq, end: contextSeq } }) + }, { + surfaceOp: { op: 'replace', start: contextSeq, end: contextSeq }, + sourceEventSeqs: [contextSeq], + }) const afterCompact = await ctx.tools.execute({ callId: CallId('read-after-compact'),