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" },