From f320a433754248f6b4c862ee36153ac5b1d08f8a Mon Sep 17 00:00:00 2001 From: Sinity Date: Sat, 5 Sep 2026 12:29:39 +0200 Subject: [PATCH 1/4] wip: checkpoint of interrupted lane lineage-links Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01DNGJ3awfNrsLaMdHgQZvid --- polylogue/archive/topology/edge.py | 20 + polylogue/sources/origin_specs.py | 2 + polylogue/sources/parsers/base_models.py | 5 + .../sources/parsers/claude/code_parser.py | 84 +++- .../sources/parsers/claude/orchestration.py | 23 ++ .../archive_tiers/archive_tiers_specs.py | 6 + .../storage/sqlite/archive_tiers/index.py | 10 +- .../storage/sqlite/archive_tiers/write.py | 261 ++++++++++--- .../sqlite/queries/message_query_reads.py | 1 + .../storage/test_dispatch_link_resolution.py | 364 ++++++++++++++++++ 10 files changed, 722 insertions(+), 54 deletions(-) create mode 100644 tests/unit/storage/test_dispatch_link_resolution.py diff --git a/polylogue/archive/topology/edge.py b/polylogue/archive/topology/edge.py index f80ea17d25..34391d5e5a 100644 --- a/polylogue/archive/topology/edge.py +++ b/polylogue/archive/topology/edge.py @@ -3,6 +3,7 @@ from __future__ import annotations from datetime import datetime, timezone +from typing import Literal, get_args from pydantic import BaseModel, ConfigDict, Field, field_validator @@ -40,6 +41,25 @@ def _now_isoformat() -> str: return datetime.now(timezone.utc).isoformat() +# Why ``session_links.parent_tool_use_block_id`` is NULL on a resolved edge. +# Persisted under ``evidence_json.dispatch_reason``; removed once a block +# binds. Every member is a refusal to guess, never an ordinal fallback. +DispatchResolutionReason = Literal[ + # The origin declares no parent-side dispatch identity. + "origin-no-dispatch-identity", + # The parent carries no dispatch observation naming this child. + "dispatch-evidence-absent", + # Evidence names a tool id the parent has no tool_use block for. + "dispatch-block-missing", + # More than one parent tool_use block carries the named tool id. + "dispatch-tool-id-duplicate", + # Witnesses name different tool ids for this child, or one tool id + # names children that resolve to different sessions. + "dispatch-identity-contradiction", +] +DISPATCH_RESOLUTION_REASONS: frozenset[str] = frozenset(get_args(DispatchResolutionReason)) + + class TopologyEdgeRecord(BaseModel): """Runtime row for the ``session_links`` table. diff --git a/polylogue/sources/origin_specs.py b/polylogue/sources/origin_specs.py index 1b281addf6..4e8ec831ec 100644 --- a/polylogue/sources/origin_specs.py +++ b/polylogue/sources/origin_specs.py @@ -1230,8 +1230,10 @@ def _claude_code_spec() -> OriginSpec: parent_dispatch=TopologyCapability( "positive-derived", ( + "claude_code user.toolUseResult.agentId + tool_result.tool_use_id (Agent/Task result)", "claude_code progress.data.type=agent_progress.parentToolUseID", "claude_code progress.data.childSessionId/agentId when present", + "claude_code subagents/agent-*.meta.json toolUseId (source tier, child-bound)", ), "parent-side dispatch evidence is retained only when the wire carries an exact child identity", ), diff --git a/polylogue/sources/parsers/base_models.py b/polylogue/sources/parsers/base_models.py index b511af7570..f4ee0d3768 100644 --- a/polylogue/sources/parsers/base_models.py +++ b/polylogue/sources/parsers/base_models.py @@ -468,6 +468,11 @@ class ParsedDispatchObservation(BaseModel): child_provider_id: str | None = None child_identity_namespace: str = "provider-session" observation_kind: Literal["parent_dispatch"] = "parent_dispatch" + # Display metadata the dispatching side recorded about the child + # (Claude Code ``toolUseResult.agentType`` / ``description``). Never an + # identity input to the resolver. + agent_type: str | None = None + description: str | None = None first_seen: str | None = None last_seen: str | None = None resolution_reason: ( diff --git a/polylogue/sources/parsers/claude/code_parser.py b/polylogue/sources/parsers/claude/code_parser.py index 7aa62af71d..99069b00f0 100644 --- a/polylogue/sources/parsers/claude/code_parser.py +++ b/polylogue/sources/parsers/claude/code_parser.py @@ -584,9 +584,70 @@ def _sidecar_evidence_payload(record_type: str, item: dict[str, object]) -> dict @dataclass class _DelegationProgressStats: count: int = 0 + result_count: int = 0 first_seen: str | None = None last_seen: str | None = None child_provider_ids: set[str] = field(default_factory=set) + agent_type: str | None = None + description: str | None = None + + +def _subagent_transcript_stem(agent_id: str) -> str: + """Return the provider name a dispatched child claims for itself. + + Claude Code writes a subagent's transcript to + ``/subagents/agent-.jsonl`` and the child parser claims + that file stem as its provider alias; parent-side records carry the bare + ``agentId``. The stem is the provider's own naming, not an inference. + """ + return agent_id if agent_id.startswith("agent-") else f"agent-{agent_id}" + + +def _accumulate_dispatch_result( + item: dict[str, object], + timestamp: str | None, + accumulator: dict[str, _DelegationProgressStats], +) -> bool: + """Fold a ``user`` record's ``toolUseResult.agentId`` into its dispatch edge. + + The Agent/Task tool's result record names the spawned child (``agentId``) + and, through its ``tool_result`` content segment, the dispatching + ``tool_use_id``: exact parent-side evidence binding one tool-use block to + one child. Returns whether the record carried such evidence. + """ + if item.get("type") != "user": + return False + tool_result = item.get("toolUseResult") + if not isinstance(tool_result, dict): + return False + agent_id = _string_field(tool_result, "agentId") + if not agent_id: + return False + message = item.get("message") + content = message.get("content") if isinstance(message, dict) else None + if not isinstance(content, list): + return False + tool_use_ids = { + str(segment["tool_use_id"]) + for segment in content + if isinstance(segment, dict) + and segment.get("type") == "tool_result" + and isinstance(segment.get("tool_use_id"), str) + and segment["tool_use_id"] + } + if len(tool_use_ids) != 1: + return False + entry = accumulator.setdefault(next(iter(tool_use_ids)), _DelegationProgressStats()) + entry.child_provider_ids.add(_subagent_transcript_stem(agent_id)) + entry.result_count += 1 + entry.agent_type = _string_field(tool_result, "agentType") or entry.agent_type + entry.description = _string_field(tool_result, "description") or entry.description + if timestamp: + if entry.first_seen is None or timestamp < entry.first_seen: + entry.first_seen = timestamp + if entry.last_seen is None or timestamp > entry.last_seen: + entry.last_seen = timestamp + return True def _accumulate_delegation_progress( @@ -615,10 +676,14 @@ def _accumulate_delegation_progress( # The dispatched child is named only inside the progress payload. The # record envelope's identity fields name the transcript that emitted the # tick -- its own session, which is the dispatching parent. - for key in ("childSessionId", "child_session_id", "agentId", "agent_id"): + for key in ("childSessionId", "child_session_id"): value = _string_field(data, key) if value: entry.child_provider_ids.add(value) + for key in ("agentId", "agent_id"): + value = _string_field(data, key) + if value: + entry.child_provider_ids.add(_subagent_transcript_stem(value)) entry.count += 1 if timestamp: if entry.first_seen is None or timestamp < entry.first_seen: @@ -1697,6 +1762,7 @@ def _fold_code_record(acc: _SessionAccumulator, index: int, item: dict[str, obje timestamp=timestamp, ) ) + _accumulate_dispatch_result(item, timestamp, acc.delegation_progress) tool_execution_payload = _tool_execution_result_payload(item) if tool_execution_payload is not None: acc.session_events.append( @@ -1855,6 +1921,8 @@ def _finalize_code_session(acc: _SessionAccumulator) -> ParsedSession: observation = ParsedDispatchObservation( provider_tool_id=parent_tool_use_id, child_provider_id=child_provider_ids[0] if len(child_provider_ids) == 1 else None, + agent_type=stats.agent_type, + description=stats.description, first_seen=stats.first_seen, last_seen=stats.last_seen, resolution_reason=( @@ -1865,12 +1933,11 @@ def _finalize_code_session(acc: _SessionAccumulator) -> ParsedSession: else None ), ) - observation_payload = observation.model_dump() - observation_payload["parent_tool_use_id"] = parent_tool_use_id - observation_payload["progress_tick_count"] = stats.count - observation_payload["summary"] = ( - f"delegated work under tool_use {parent_tool_use_id} ({stats.count} progress ticks)" + summary = ( + f"delegated work under tool_use {parent_tool_use_id} " + f"({stats.count} progress ticks, {stats.result_count} dispatch results)" ) + observation_payload = observation.model_dump() if len(child_provider_ids) > 1: observation_payload["child_provider_ids"] = list(child_provider_ids) acc.session_events.append( @@ -1881,10 +1948,9 @@ def _finalize_code_session(acc: _SessionAccumulator) -> ParsedSession: payload={ "parent_tool_use_id": parent_tool_use_id, "progress_tick_count": stats.count, - "first_seen": stats.first_seen, - "last_seen": stats.last_seen, + "dispatch_result_count": stats.result_count, **observation_payload, - "summary": f"delegated work under tool_use {parent_tool_use_id} ({stats.count} progress ticks)", + "summary": summary, }, ) ) diff --git a/polylogue/sources/parsers/claude/orchestration.py b/polylogue/sources/parsers/claude/orchestration.py index 3583eb771c..f5f659e19c 100644 --- a/polylogue/sources/parsers/claude/orchestration.py +++ b/polylogue/sources/parsers/claude/orchestration.py @@ -75,6 +75,16 @@ "adopted_session_id", "unresolved", "error", + # agent-*.meta.json dispatch sidecar: the dispatching tool_use id is + # the exact join key to the parent block; agentType/description are + # display metadata. + "toolUseId", + "tool_use_id", + "agentType", + "agent_type", + "description", + "spawnDepth", + "spawn_depth", } ) _JOURNAL_FIELDS = _DOCUMENT_FIELDS | frozenset({"type", "event", "key", "ordinal", "retryOf", "retry_of"}) @@ -122,6 +132,19 @@ def transcript_path(self) -> str | None: def meta_path(self) -> str | None: return _first_string(self.payload, "metaPath", "meta_path") + @property + def tool_use_id(self) -> str | None: + """The parent tool_use that dispatched this agent (sidecar ``toolUseId``).""" + return _first_string(self.payload, "toolUseId", "tool_use_id") + + @property + def agent_type(self) -> str | None: + return _first_string(self.payload, "agentType", "agent_type") + + @property + def description(self) -> str | None: + return _first_string(self.payload, "description") + @property def is_result(self) -> bool: return any( diff --git a/polylogue/storage/sqlite/archive_tiers/archive_tiers_specs.py b/polylogue/storage/sqlite/archive_tiers/archive_tiers_specs.py index 26ed691e3d..948d1e1d99 100644 --- a/polylogue/storage/sqlite/archive_tiers/archive_tiers_specs.py +++ b/polylogue/storage/sqlite/archive_tiers/archive_tiers_specs.py @@ -943,6 +943,12 @@ def _make_table_spec( -- child inherited, and `inheritance` records whether the child shares the -- parent's leading prefix ('prefix-sharing') or is a fresh spawn that merely -- references the parent ('spawned-fresh'). NULL until the parent is resolved. + -- Positional content ancestry only: the prefix is aligned by message + -- position and content signature, and composition orders inherited + -- prefix then child tail by position. The child's tail may carry + -- occurred_at_ms earlier than this message (auto-compaction replays + -- original timestamps; observer clocks skew); no reader may treat the + -- branch point as a timestamp lower bound for the tail. -- Deliberately NOT a FK: message_id is deterministic, so a parent full-replace -- re-ingest re-creates the same id. An `ON DELETE SET NULL` FK would instead -- null this during the parent's DELETE step and permanently break the child's diff --git a/polylogue/storage/sqlite/archive_tiers/index.py b/polylogue/storage/sqlite/archive_tiers/index.py index bc46f0d5a8..5d59ad29de 100644 --- a/polylogue/storage/sqlite/archive_tiers/index.py +++ b/polylogue/storage/sqlite/archive_tiers/index.py @@ -448,7 +448,15 @@ # SEMANTIC_REPARSE: links materialized under v92 recorded the emitting # session's own name as a competing child identity, and no clone-safe SQL # delta can recover the dispatch join key those rows refused. -INDEX_SCHEMA_VERSION = 93 +# v94 lowers the Agent/Task tool result (``toolUseResult.agentId`` + +# ``tool_result.tool_use_id``) into the parent-side dispatch observation and +# names the child by its transcript stem; the resolver joins that +# observation, the child's ``agent-*.meta.json`` sidecar ``toolUseId``, and the +# exact parent tool_use block, recording a typed ``dispatch_reason`` when it +# refuses. SEMANTIC_REPARSE: parents materialized under v93 carry no +# observation for result-only dispatches (the live wire shape), so the join +# key cannot be derived from stored rows. +INDEX_SCHEMA_VERSION = 94 # polylogue-v6i3: shared WHEN-clause fragment gating the blocks_command_trigram # trigger BODIES on the same dedicated bulk-build guard row messages_fts's diff --git a/polylogue/storage/sqlite/archive_tiers/write.py b/polylogue/storage/sqlite/archive_tiers/write.py index aafa988752..b20c4a95c1 100644 --- a/polylogue/storage/sqlite/archive_tiers/write.py +++ b/polylogue/storage/sqlite/archive_tiers/write.py @@ -32,6 +32,7 @@ HOOK_CONTRADICTED_LINK_METHOD, HOOK_DERIVED_LINK_METHODS, HOOK_SUPERSEDED_LINK_METHOD, + DispatchResolutionReason, TopologyEdgeStatus, TopologyEdgeType, branch_type_to_edge_type, @@ -57,7 +58,7 @@ from polylogue.core.timestamps import parse_timestamp from polylogue.logging import get_logger from polylogue.pipeline.ids import MessageOwnerResolution, attachment_message_owner_key, message_owner_resolution -from polylogue.sources.origin_specs import lowering_fingerprint, parser_fingerprint_for_origin +from polylogue.sources.origin_specs import lowering_fingerprint, origin_specs, parser_fingerprint_for_origin from polylogue.sources.parsers.base import ( ParseAccounting, ParsedAttachment, @@ -68,6 +69,7 @@ ParsedSessionEvent, ) from polylogue.sources.parsers.base_support import derive_attachment_provenance +from polylogue.sources.parsers.claude.orchestration import parse_claude_orchestration_artifact from polylogue.sources.tool_outcomes import derive_tool_outcomes as _derive_tool_outcomes from polylogue.storage.blob_store import get_blob_store from polylogue.storage.fts.fts_lifecycle import message_fts_triggers_present_sync @@ -1101,6 +1103,8 @@ def add_timing(name: str, started_at: float) -> None: } if invalidated_identity_children: graph_kwargs["invalidated_session_ids"] = invalidated_identity_children + if source_conn is not None: + graph_kwargs["source_conn"] = source_conn _resolve_session_graph(conn, session_id, native_id, origin.value, **graph_kwargs) add_timing("index.graph_resolve", t0) t0 = time.perf_counter() @@ -4249,12 +4253,15 @@ def _write_session_link( if not dst_native_id: return link_type = branch_type_to_edge_type(session.branch_type, default=TopologyEdgeType.BRANCH).value - parent_tool_use_block_id = _resolve_parent_tool_use_block_id(conn, session) - method = "parser-parent" if parent_tool_use_block_id is None else "parent-tool-use-id" + dispatch = _resolve_parent_tool_use_block(conn, session, source_conn=source_conn) + parent_tool_use_block_id = dispatch.block_id + method = dispatch.method or "parser-parent" evidence: dict[str, object] = {"parent_session_provider_id": session.parent_session_provider_id} identity_reason = _session_target_resolution_reason(conn, origin, dst_native_id) if identity_reason is not None: evidence["resolution_reason"] = identity_reason + if dispatch.reason is not None: + evidence["dispatch_reason"] = dispatch.reason status: str | None = None # A conflict is scoped to (child, link_type), NOT to the primary key. @@ -4350,22 +4357,26 @@ def _session_target_resolution_reason(conn: sqlite3.Connection, origin: str, pro return "target-not-yet-observed" -def _resolve_parent_tool_use_block_id(conn: sqlite3.Connection, session: ParsedSession) -> str | None: +def _resolve_parent_tool_use_block( + conn: sqlite3.Connection, + session: ParsedSession, + *, + source_conn: sqlite3.Connection | None = None, +) -> _DispatchResolution: """Resolve parent-side dispatch evidence to the exact TOOL_USE block. A supplied parent identity scopes the lookup to that exact resolved - session. ``None`` (not found / not supplied) leaves the column NULL, - never a guess. + session. A parent not yet in the archive is pending, not refused. """ parent_id = getattr(session, "parent_session_provider_id", None) if not parent_id: - return None + return _DISPATCH_PENDING origin = origin_from_provider(session.source_name).value parent_session_id = _existing_parent_session_id(conn, session, origin) if parent_session_id is None: - return None + return _DISPATCH_PENDING child_session_id = archive_session_id(origin, session.provider_session_id.strip()) - return _resolve_parent_dispatch_block_id(conn, parent_session_id, child_session_id) + return _resolve_parent_dispatch_block(conn, parent_session_id, child_session_id, source_conn=source_conn) def _branch_type_from_link_type(link_type: object) -> str | None: @@ -4493,6 +4504,7 @@ def _resolve_session_graph( bulk_fts: bool = False, bulk_build: bool = False, invalidated_session_ids: set[str] | None = None, + source_conn: sqlite3.Connection | None = None, ) -> None: def record_substage(name: str, started_at: float) -> None: if add_timing is not None: @@ -4512,7 +4524,7 @@ def record_substage(name: str, started_at: float) -> None: _resolve_outbound_session_links(conn, session_id, origin) record_substage("outbound_links", t0) t0 = time.perf_counter() - _refill_inbound_dispatch_block_ids(conn, session_id) + _refill_inbound_dispatch_block_ids(conn, session_id, source_conn=source_conn) record_substage("inbound_dispatch_blocks", t0) t0 = time.perf_counter() has_outbound_link = ( @@ -4572,14 +4584,14 @@ def record_substage(name: str, started_at: float) -> None: observed_at_ms=int(time.time() * 1000), ) continue - parent_tool_use_block_id = _resolve_parent_dispatch_block_id(conn, session_id, child_id) + dispatch = _resolve_parent_dispatch_block(conn, session_id, child_id, source_conn=source_conn) conn.execute( - """ + f""" UPDATE session_links SET resolved_dst_session_id = ?, resolved_at_ms = COALESCE(resolved_at_ms, observed_at_ms), parent_tool_use_block_id = COALESCE(parent_tool_use_block_id, ?), - evidence_json = json_remove(evidence_json, '$.resolution_reason'), + evidence_json = json_remove({_DISPATCH_REASON_EVIDENCE_SQL}, '$.resolution_reason'), method = CASE WHEN parent_tool_use_block_id IS NULL AND ? IS NOT NULL THEN 'parent-tool-use-id' ELSE method @@ -4590,7 +4602,16 @@ def record_substage(name: str, started_at: float) -> None: AND resolved_dst_session_id IS NULL AND status IS NULL """, - (session_id, parent_tool_use_block_id, parent_tool_use_block_id, child_id, dst_native_id, link_type), + ( + session_id, + dispatch.block_id, + dispatch.reason, + dispatch.reason, + dispatch.block_id, + child_id, + dst_native_id, + link_type, + ), ) resolved_child_ids.append(child_id) # Deferred tail extraction (#2467): a child ingested before its parent was @@ -4620,7 +4641,12 @@ def record_substage(name: str, started_at: float) -> None: record_substage("projection_refresh", t0) -def _refill_inbound_dispatch_block_ids(conn: sqlite3.Connection, parent_session_id: str) -> None: +def _refill_inbound_dispatch_block_ids( + conn: sqlite3.Connection, + parent_session_id: str, + *, + source_conn: sqlite3.Connection | None = None, +) -> None: """Rebind resolved children to this parent's dispatch blocks. Writing a parent replaces its messages and blocks, and @@ -4628,7 +4654,10 @@ def _refill_inbound_dispatch_block_ids(conn: sqlite3.Connection, parent_session_ every inbound child edge loses the join key while the replacement reinserts the same deterministic block ids. Identity resolution revisits only unresolved edges, so an already-resolved child is repaired here or - not at all. + not at all. This is also where a child written before its parent carried + dispatch evidence converges: parent-first and child-first ingest reach + the same edge. A refusal is re-typed each time so the recorded reason + reflects the parent as it stands now. """ rows = conn.execute( """SELECT src_session_id, dst_origin, dst_native_id, link_type @@ -4639,16 +4668,24 @@ def _refill_inbound_dispatch_block_ids(conn: sqlite3.Connection, parent_session_ (parent_session_id,), ).fetchall() for src_session_id, dst_origin, dst_native_id, link_type in rows: - block_id = _resolve_parent_dispatch_block_id(conn, parent_session_id, str(src_session_id)) - if block_id is None: - continue + dispatch = _resolve_parent_dispatch_block(conn, parent_session_id, str(src_session_id), source_conn=source_conn) conn.execute( - """UPDATE session_links + f"""UPDATE session_links SET parent_tool_use_block_id = ?, - method = CASE WHEN method = 'parser-parent' THEN 'parent-tool-use-id' ELSE method END + evidence_json = {_DISPATCH_REASON_EVIDENCE_SQL}, + method = CASE WHEN method = 'parser-parent' AND ? IS NOT NULL THEN 'parent-tool-use-id' ELSE method END WHERE src_session_id = ? AND dst_origin = ? AND dst_native_id = ? AND link_type = ? AND parent_tool_use_block_id IS NULL""", - (block_id, src_session_id, dst_origin, dst_native_id, link_type), + ( + dispatch.block_id, + dispatch.reason, + dispatch.reason, + dispatch.block_id, + src_session_id, + dst_origin, + dst_native_id, + link_type, + ), ) @@ -6379,6 +6416,7 @@ def own_signatures(target_session_id: str) -> list[tuple[str, str]]: AND resolved_dst_session_id IS NOT NULL AND branch_point_message_id IS NOT NULL AND {topology_status_composes_sql()} + ORDER BY link_type, dst_origin, dst_native_id LIMIT 1 """, (cursor_session_id,), @@ -7178,6 +7216,7 @@ def _prefix_sharing_edge_sync(conn: sqlite3.Connection, session_id: str) -> tupl AND resolved_dst_session_id IS NOT NULL AND branch_point_message_id IS NOT NULL AND {topology_status_composes_sql()} + ORDER BY link_type, dst_origin, dst_native_id LIMIT 1 """, (session_id,), @@ -7270,26 +7309,131 @@ def _canonical_identity_session_ids(conn: sqlite3.Connection, origin: str, value return resolved -def _resolve_parent_dispatch_block_id( - conn: sqlite3.Connection, parent_session_id: str, child_session_id: str -) -> str | None: - """Resolve parent-side delegation evidence after either session arrives. +@dataclass(frozen=True, slots=True) +class _DispatchResolution: + """Outcome of binding a child edge to its parent's dispatching block. + + Exactly one of ``block_id`` / ``reason`` is set: a refusal always names + why, and a bound block never carries a stale reason. + """ + + block_id: str | None + reason: DispatchResolutionReason | None + + @property + def method(self) -> str | None: + return "parent-tool-use-id" if self.block_id is not None else None + + +_DISPATCH_PENDING = _DispatchResolution(None, None) +"""The parent is not in the archive yet; nothing can be said either way.""" + + +_ORIGIN_SPECS_BY_VALUE: dict[str, Any] | None = None + + +def _origin_carries_dispatch_identity(origin: str) -> bool: + global _ORIGIN_SPECS_BY_VALUE + if _ORIGIN_SPECS_BY_VALUE is None: + _ORIGIN_SPECS_BY_VALUE = {spec.origin.value: spec for spec in origin_specs()} + spec = _ORIGIN_SPECS_BY_VALUE.get(origin) + if spec is None: + return False + return spec.topology_capabilities.parent_dispatch.state != "structurally-absent" + + +def _session_provider_values(conn: sqlite3.Connection, session_id: str) -> set[str]: + values = { + str(row[0]) + for row in conn.execute( + """SELECT provider_value FROM session_identity_claims + WHERE claimant_session_id = ? AND identity_namespace = 'provider-session'""", + (session_id,), + ).fetchall() + } + row = conn.execute("SELECT native_id FROM sessions WHERE session_id = ?", (session_id,)).fetchone() + if row is not None and row[0]: + values.add(str(row[0])) + return values - Provider names are compared as the sessions they resolve to: several exact - names for one child are one identity, and only names resolving to - different sessions contradict each other. + +def _sidecar_dispatch_tool_ids( + source_conn: sqlite3.Connection | None, + *, + origin: str, + parent_values: set[str], + child_values: set[str], +) -> set[str]: + """Tool ids the child's ``agent-*.meta.json`` sidecar names, bound to this parent. + + The sidecar lives at ``/subagents/.meta.json`` in the + durable source tier; the parent directory must be one of the parent's own + provider names, so a sidecar can never bind to a different session that + happens to share a child stem. + """ + if source_conn is None or origin != Origin.CLAUDE_CODE_SESSION.value: + return set() + stems = {value for value in child_values if value.startswith("agent-") and ":" not in value} + if not stems: + return set() + if ( + source_conn.execute("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'raw_sessions'").fetchone() + is None + ): + return set() + tool_ids: set[str] = set() + store = get_blob_store() + for stem in sorted(stems): + rows = source_conn.execute( + "SELECT source_path, blob_hash FROM raw_sessions WHERE origin = ? AND source_path LIKE ?", + (origin, f"%/subagents/{stem}.meta.json"), + ).fetchall() + for source_path, blob_hash in rows: + parts = str(source_path).replace("\\", "/").split("/") + if len(parts) < 3 or parts[-3] not in parent_values: + continue + try: + payload = store.read_all(bytes(blob_hash).hex()) + artifact = parse_claude_orchestration_artifact(str(source_path), payload) + except (OSError, ValueError): + continue + if artifact is None: + continue + tool_ids.update(fact.tool_use_id for fact in artifact.facts if fact.tool_use_id) + return tool_ids + + +def _resolve_parent_dispatch_block( + conn: sqlite3.Connection, + parent_session_id: str, + child_session_id: str, + *, + source_conn: sqlite3.Connection | None = None, +) -> _DispatchResolution: + """Bind a child edge to the exact parent tool_use block that dispatched it. + + Two exact witnesses are joined: the parent's own dispatch observations + (``claude_delegation_progress`` events whose child identity resolves to + ``child_session_id``) and the child's dispatch sidecar in the source tier. + Provider names are compared as the sessions they resolve to: several + exact names for one child are one identity, and only names resolving to + different sessions contradict each other. Every refusal is typed; there + is no ordinal, count, timestamp, or nearest-call fallback. """ origin_row = conn.execute("SELECT origin FROM sessions WHERE session_id = ?", (parent_session_id,)).fetchone() if origin_row is None: - return None + return _DISPATCH_PENDING origin = str(origin_row[0]) + if not _origin_carries_dispatch_identity(origin): + return _DispatchResolution(None, "origin-no-dispatch-identity") rows = conn.execute( """SELECT source_message_provider_id, payload_json FROM session_events WHERE session_id = ? AND event_type = 'claude_delegation_progress'""", (parent_session_id,), ).fetchall() - matching_blocks: set[str] = set() + tool_ids: set[str] = set() + contradicted = False for source_id, payload_json in rows: try: payload = json.loads(str(payload_json)) @@ -7310,20 +7454,49 @@ def _resolve_parent_dispatch_block_id( identity_values = _dispatch_child_identity_values(observation, observation_payload) if not identity_values: continue - if _canonical_identity_session_ids(conn, origin, identity_values) != {child_session_id}: + resolved = _canonical_identity_session_ids(conn, origin, identity_values) + if resolved is None or child_session_id not in resolved: continue - block_rows = conn.execute( - """SELECT b.block_id FROM blocks b - JOIN messages m ON m.message_id = b.message_id - WHERE b.tool_id = ? AND b.block_type = 'tool_use' AND m.session_id = ? - ORDER BY b.block_id""", - (observation.provider_tool_id, parent_session_id), - ).fetchall() - if len(block_rows) == 1: - matching_blocks.add(str(block_rows[0][0])) - if len(matching_blocks) == 1: - return next(iter(matching_blocks)) - return None + if len(resolved) > 1: + contradicted = True + continue + tool_ids.add(observation.provider_tool_id) + tool_ids |= _sidecar_dispatch_tool_ids( + source_conn, + origin=origin, + parent_values=_session_provider_values(conn, parent_session_id), + child_values=_session_provider_values(conn, child_session_id), + ) + if contradicted or len(tool_ids) > 1: + return _DispatchResolution(None, "dispatch-identity-contradiction") + if not tool_ids: + return _DispatchResolution(None, "dispatch-evidence-absent") + (tool_id,) = tool_ids + block_rows = conn.execute( + """SELECT b.block_id FROM blocks b + JOIN messages m ON m.message_id = b.message_id + WHERE b.tool_id = ? AND b.block_type = 'tool_use' AND m.session_id = ? + ORDER BY b.block_id""", + (tool_id, parent_session_id), + ).fetchall() + if not block_rows: + return _DispatchResolution(None, "dispatch-block-missing") + if len(block_rows) > 1: + return _DispatchResolution(None, "dispatch-tool-id-duplicate") + return _DispatchResolution(str(block_rows[0][0]), None) + + +# ``evidence_json`` update fragment: bind the reason, or clear it once a block +# binds. Bound as (reason, reason). Older rows default to a JSON array, so the +# object form is established before json_set. +_DISPATCH_REASON_EVIDENCE_SQL = """ + CASE WHEN ? IS NULL + THEN json_remove(evidence_json, '$.dispatch_reason') + ELSE json_set( + CASE WHEN json_type(evidence_json) = 'object' THEN evidence_json ELSE '{}' END, + '$.dispatch_reason', ?) + END +""" def _write_session_identity_claims( diff --git a/polylogue/storage/sqlite/queries/message_query_reads.py b/polylogue/storage/sqlite/queries/message_query_reads.py index 6da5aee64b..5cf1408349 100644 --- a/polylogue/storage/sqlite/queries/message_query_reads.py +++ b/polylogue/storage/sqlite/queries/message_query_reads.py @@ -57,6 +57,7 @@ async def _prefix_sharing_edge(conn: aiosqlite.Connection, session_id: str) -> t AND resolved_dst_session_id IS NOT NULL AND branch_point_message_id IS NOT NULL AND {topology_status_composes_sql()} + ORDER BY link_type, dst_origin, dst_native_id LIMIT 1 """, (session_id,), diff --git a/tests/unit/storage/test_dispatch_link_resolution.py b/tests/unit/storage/test_dispatch_link_resolution.py new file mode 100644 index 0000000000..e9b9f62740 --- /dev/null +++ b/tests/unit/storage/test_dispatch_link_resolution.py @@ -0,0 +1,364 @@ +"""Child session links bind to the exact parent tool_use block that dispatched them. + +Evidence enters through the production Claude Code parser: the Agent/Task +tool result record carries ``toolUseResult.agentId`` next to the +``tool_result.tool_use_id`` that names the dispatching block, and the child's +``agent-*.meta.json`` sidecar in the source tier carries ``toolUseId``. The +canonical session-link writer joins those exact keys with the parent's +tool_use block; every refusal is a typed ``dispatch_reason``. + +Anti-vacuity for the whole module: restoring an ordinal, nearest-call, +count, or timestamp fallback in ``_resolve_parent_dispatch_block`` makes the +fan-out test bind a child to the wrong block and the refusal tests bind a +block where none may be bound. +""" + +from __future__ import annotations + +import json +import sqlite3 +from pathlib import Path + +from polylogue.archive.message.roles import Role +from polylogue.core.enums import BlockType, Provider +from polylogue.sources.parsers.base import ParsedContentBlock, ParsedMessage, ParsedSession +from polylogue.sources.parsers.claude import parse_code +from polylogue.storage.blob_store import get_blob_store +from polylogue.storage.sqlite.archive_tiers.bootstrap import initialize_archive_tier +from polylogue.storage.sqlite.archive_tiers.types import ArchiveTier +from polylogue.storage.sqlite.archive_tiers.write import write_parsed_session_to_archive + +_PARENT = "0d9f1c2e-parent-uuid" +_T0 = "2026-05-28T00:59:00.000Z" + + +def _index_conn(path: Path) -> sqlite3.Connection: + conn = sqlite3.connect(path) + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA foreign_keys = ON") + initialize_archive_tier(conn, ArchiveTier.INDEX) + return conn + + +def _source_conn(path: Path) -> sqlite3.Connection: + conn = sqlite3.connect(path) + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA foreign_keys = ON") + initialize_archive_tier(conn, ArchiveTier.SOURCE) + return conn + + +def _parent_records( + dispatches: list[tuple[str, str]], + *, + with_tool_use: bool = True, + with_result: bool = True, + session_id: str = _PARENT, +) -> list[dict[str, object]]: + """Provider-shaped dispatching transcript: one Agent tool_use + result per dispatch.""" + records: list[dict[str, object]] = [ + { + "type": "user", + "uuid": f"{session_id}-u0", + "sessionId": session_id, + "timestamp": _T0, + "message": {"role": "user", "content": "delegate the audit"}, + } + ] + for index, (tool_id, agent_id) in enumerate(dispatches): + if with_tool_use: + records.append( + { + "type": "assistant", + "uuid": f"{session_id}-a{index}", + "sessionId": session_id, + "timestamp": _T0, + "message": { + "role": "assistant", + "content": [ + { + "type": "tool_use", + "id": tool_id, + "name": "Agent", + "input": {"description": f"worker {index}", "prompt": "audit"}, + } + ], + }, + } + ) + if with_result: + records.append( + { + "type": "user", + "uuid": f"{session_id}-r{index}", + "sessionId": session_id, + "timestamp": _T0, + "message": { + "role": "user", + "content": [ + { + "tool_use_id": tool_id, + "type": "tool_result", + "content": [{"type": "text", "text": "Async agent launched successfully."}], + } + ], + }, + "toolUseResult": { + "isAsync": True, + "status": "async_launched", + "agentId": agent_id, + "description": f"worker {index}", + }, + } + ) + return records + + +def _child_records(agent_id: str, *, parent: str = _PARENT) -> list[dict[str, object]]: + return [ + { + "parentUuid": None, + "isSidechain": True, + "promptId": "prompt-1", + "agentId": agent_id, + "type": "user", + "uuid": f"{agent_id}-u0", + "sessionId": parent, + "timestamp": _T0, + "message": {"role": "user", "content": f"You are worker {agent_id}."}, + }, + { + "parentUuid": f"{agent_id}-u0", + "isSidechain": True, + "agentId": agent_id, + "type": "assistant", + "uuid": f"{agent_id}-a0", + "sessionId": parent, + "timestamp": _T0, + "message": {"role": "assistant", "content": [{"type": "text", "text": "done"}]}, + }, + ] + + +def _write_parent(conn: sqlite3.Connection, records: list[dict[str, object]], **kwargs: object) -> str: + return write_parsed_session_to_archive(conn, parse_code(records, _PARENT), **kwargs) # type: ignore[arg-type] + + +def _write_child(conn: sqlite3.Connection, agent_id: str, **kwargs: object) -> str: + return write_parsed_session_to_archive(conn, parse_code(_child_records(agent_id), f"agent-{agent_id}"), **kwargs) # type: ignore[arg-type] + + +def _link(conn: sqlite3.Connection, child_id: str) -> sqlite3.Row: + rows = conn.execute( + """SELECT resolved_dst_session_id, parent_tool_use_block_id, method, evidence_json + FROM session_links WHERE src_session_id = ?""", + (child_id,), + ).fetchall() + assert len(rows) == 1, rows + return rows[0] + + +def _tool_use_block_id(conn: sqlite3.Connection, tool_id: str) -> str: + rows = conn.execute( + "SELECT block_id FROM blocks WHERE tool_id = ? AND block_type = 'tool_use'", (tool_id,) + ).fetchall() + assert len(rows) == 1, rows + return str(rows[0][0]) + + +def _dispatch_reason(row: sqlite3.Row) -> str | None: + value = json.loads(row["evidence_json"]).get("dispatch_reason") + return None if value is None else str(value) + + +def test_parent_first_binds_child_to_exact_dispatch_block(tmp_path: Path) -> None: + """Red if ``toolUseResult.agentId`` stops lowering into the dispatch observation.""" + conn = _index_conn(tmp_path / "index.db") + parent_id = _write_parent(conn, _parent_records([("call_1", "a1")])) + child_id = _write_child(conn, "a1") + + link = _link(conn, child_id) + assert link["resolved_dst_session_id"] == parent_id + assert link["parent_tool_use_block_id"] == _tool_use_block_id(conn, "call_1") + assert link["method"] == "parent-tool-use-id" + assert _dispatch_reason(link) is None + + +def test_child_first_converges_to_the_same_edge(tmp_path: Path) -> None: + """Order independence: the child arriving before its parent yields an identical edge. + + Red if ``_refill_inbound_dispatch_block_ids`` stops running on the parent + write, or if the inbound resolution loop stops binding the block. + """ + first = _index_conn(tmp_path / "parent-first.db") + _write_parent(first, _parent_records([("call_1", "a1")])) + child_first_edge = dict(_link(first, _write_child(first, "a1"))) + + second = _index_conn(tmp_path / "child-first.db") + child_id = _write_child(second, "a1") + pending = _link(second, child_id) + assert pending["resolved_dst_session_id"] is None + assert pending["parent_tool_use_block_id"] is None + _write_parent(second, _parent_records([("call_1", "a1")])) + parent_first_edge = dict(_link(second, child_id)) + + assert parent_first_edge == child_first_edge + assert parent_first_edge["parent_tool_use_block_id"] == _tool_use_block_id(second, "call_1") + + +def test_fan_out_binds_each_child_to_its_own_block(tmp_path: Path) -> None: + """Two dispatches in one parent stay distinguishable by provider tool id. + + Red under any ordinal or nearest-call pairing: the children are written + in reverse dispatch order, so a positional guess swaps the blocks. + """ + conn = _index_conn(tmp_path / "index.db") + _write_parent(conn, _parent_records([("call_1", "a1"), ("call_2", "a2")])) + second_child = _write_child(conn, "a2") + first_child = _write_child(conn, "a1") + + assert _link(conn, first_child)["parent_tool_use_block_id"] == _tool_use_block_id(conn, "call_1") + assert _link(conn, second_child)["parent_tool_use_block_id"] == _tool_use_block_id(conn, "call_2") + + +def test_missing_dispatch_evidence_is_typed_absent(tmp_path: Path) -> None: + """A parent whose result record lacks the child identity refuses, and says why. + + Red if the resolver falls back to the parent's only Agent tool_use block. + """ + conn = _index_conn(tmp_path / "index.db") + _write_parent(conn, _parent_records([("call_1", "a1")], with_result=False)) + child_id = _write_child(conn, "a1") + + link = _link(conn, child_id) + assert link["resolved_dst_session_id"] is not None + assert link["parent_tool_use_block_id"] is None + assert link["method"] == "parser-parent" + assert _dispatch_reason(link) == "dispatch-evidence-absent" + + +def test_evidence_naming_an_absent_block_is_typed_missing(tmp_path: Path) -> None: + """Red if a named-but-absent block degrades to silent NULL.""" + conn = _index_conn(tmp_path / "index.db") + _write_parent(conn, _parent_records([("call_1", "a1")], with_tool_use=False)) + child_id = _write_child(conn, "a1") + + link = _link(conn, child_id) + assert link["parent_tool_use_block_id"] is None + assert _dispatch_reason(link) == "dispatch-block-missing" + + +def test_conflicting_witnesses_are_contradicted_not_guessed(tmp_path: Path) -> None: + """Two tool ids naming the same child never resolve to either block.""" + conn = _index_conn(tmp_path / "index.db") + _write_parent(conn, _parent_records([("call_1", "a1"), ("call_2", "a1")])) + child_id = _write_child(conn, "a1") + + link = _link(conn, child_id) + assert link["parent_tool_use_block_id"] is None + assert _dispatch_reason(link) == "dispatch-identity-contradiction" + + +def _seed_sidecar(source: sqlite3.Connection, *, parent_dir: str, agent_id: str, tool_use_id: str) -> None: + payload = json.dumps( + {"agentType": "general-purpose", "description": "worker", "toolUseId": tool_use_id, "spawnDepth": 1} + ).encode() + hash_hex, size = get_blob_store().write_from_bytes(payload) + source.execute( + """INSERT INTO raw_sessions (raw_id, origin, source_path, blob_hash, blob_size, acquired_at_ms) + VALUES (?, 'claude-code-session', ?, ?, ?, 0)""", + ( + f"raw-{parent_dir}-{agent_id}", + f"/x/.claude/projects/proj/{parent_dir}/subagents/agent-{agent_id}.meta.json", + bytes.fromhex(hash_hex), + size, + ), + ) + source.commit() + + +def test_sidecar_tool_use_id_binds_when_the_parent_result_is_silent(tmp_path: Path) -> None: + """The child's ``agent-*.meta.json`` sidecar is an exact witness from the source tier. + + A decoy sidecar for the same child stem under a different parent + directory names another tool id; it must not bind, or contradict, this + edge. Red if ``parse_claude_orchestration_artifact`` drops ``toolUseId`` + or if the parent-directory binding is removed (the decoy would then + contradict the real witness). + """ + index = _index_conn(tmp_path / "index.db") + source = _source_conn(tmp_path / "source.db") + _seed_sidecar(source, parent_dir=_PARENT, agent_id="a1", tool_use_id="call_1") + _seed_sidecar(source, parent_dir="some-other-parent", agent_id="a1", tool_use_id="call_9") + + _write_parent(index, _parent_records([("call_1", "a1")], with_result=False), source_conn=source) + child_id = _write_child(index, "a1", source_conn=source) + + link = _link(index, child_id) + assert link["parent_tool_use_block_id"] == _tool_use_block_id(index, "call_1") + assert link["method"] == "parent-tool-use-id" + assert _dispatch_reason(link) is None + + +def test_sidecar_and_parent_result_disagreeing_is_a_contradiction(tmp_path: Path) -> None: + index = _index_conn(tmp_path / "index.db") + source = _source_conn(tmp_path / "source.db") + _seed_sidecar(source, parent_dir=_PARENT, agent_id="a1", tool_use_id="call_2") + + _write_parent(index, _parent_records([("call_1", "a1"), ("call_2", "a2")]), source_conn=source) + child_id = _write_child(index, "a1", source_conn=source) + + assert _link(index, child_id)["parent_tool_use_block_id"] is None + assert _dispatch_reason(_link(index, child_id)) == "dispatch-identity-contradiction" + + +def test_delegation_facts_consume_the_canonical_edge(tmp_path: Path) -> None: + """The delegation projection reads the bound block; it does not pair on its own. + + Red if ``delegation_facts_source`` regains any join other than + ``parent_tool_use_block_id = instruction_tool_use_block_id``: the + unresolved second dispatch would then be paired with the only child. + """ + conn = _index_conn(tmp_path / "index.db") + parent_id = _write_parent(conn, _parent_records([("call_1", "a1"), ("call_2", "a2")])) + child_id = _write_child(conn, "a1") + + rows = conn.execute( + """SELECT mapping_state, child_session_id, instruction_tool_use_block_id + FROM delegation_facts WHERE parent_session_id = ? + ORDER BY instruction_tool_use_block_id""", + (parent_id,), + ).fetchall() + assert [tuple(row) for row in rows] == [ + ("resolved", child_id, _tool_use_block_id(conn, "call_1")), + ("unresolved", None, _tool_use_block_id(conn, "call_2")), + ] + + +def test_origin_without_dispatch_identity_is_typed(tmp_path: Path) -> None: + """Codex declares no parent-dispatch identity; the refusal names the origin, not the evidence.""" + conn = _index_conn(tmp_path / "index.db") + + def _session(native_id: str, *, parent: str | None = None) -> ParsedSession: + return ParsedSession( + source_name=Provider.CODEX, + provider_session_id=native_id, + parent_session_provider_id=parent, + messages=[ + ParsedMessage( + provider_message_id=f"{native_id}-m0", + role=Role.USER, + text="work", + position=0, + blocks=[ParsedContentBlock(type=BlockType.TEXT, text="work")], + ) + ], + ) + + write_parsed_session_to_archive(conn, _session("codex-parent")) + child_id = write_parsed_session_to_archive(conn, _session("codex-child", parent="codex-parent")) + + link = _link(conn, child_id) + assert link["resolved_dst_session_id"] is not None + assert link["parent_tool_use_block_id"] is None + assert _dispatch_reason(link) == "origin-no-dispatch-identity" From 18b0344c56b5d7ca13a3e4a2b01e3d1cca5e1671 Mon Sep 17 00:00:00 2001 From: Sinity Date: Sat, 5 Sep 2026 12:48:25 +0200 Subject: [PATCH 2/4] wip: satisfy mypy in the dispatch-link resolver and its test --- polylogue/storage/sqlite/archive_tiers/write.py | 2 +- tests/unit/storage/test_dispatch_link_resolution.py | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/polylogue/storage/sqlite/archive_tiers/write.py b/polylogue/storage/sqlite/archive_tiers/write.py index b20c4a95c1..6349a09d97 100644 --- a/polylogue/storage/sqlite/archive_tiers/write.py +++ b/polylogue/storage/sqlite/archive_tiers/write.py @@ -7339,7 +7339,7 @@ def _origin_carries_dispatch_identity(origin: str) -> bool: spec = _ORIGIN_SPECS_BY_VALUE.get(origin) if spec is None: return False - return spec.topology_capabilities.parent_dispatch.state != "structurally-absent" + return bool(spec.topology_capabilities.parent_dispatch.state != "structurally-absent") def _session_provider_values(conn: sqlite3.Connection, session_id: str) -> set[str]: diff --git a/tests/unit/storage/test_dispatch_link_resolution.py b/tests/unit/storage/test_dispatch_link_resolution.py index e9b9f62740..33144d8eec 100644 --- a/tests/unit/storage/test_dispatch_link_resolution.py +++ b/tests/unit/storage/test_dispatch_link_resolution.py @@ -18,6 +18,7 @@ import json import sqlite3 from pathlib import Path +from typing import cast from polylogue.archive.message.roles import Role from polylogue.core.enums import BlockType, Provider @@ -155,7 +156,7 @@ def _link(conn: sqlite3.Connection, child_id: str) -> sqlite3.Row: (child_id,), ).fetchall() assert len(rows) == 1, rows - return rows[0] + return cast(sqlite3.Row, rows[0]) def _tool_use_block_id(conn: sqlite3.Connection, tool_id: str) -> str: From 483d9fc5b31c037fac20d97f4a9a853ca3e9ded9 Mon Sep 17 00:00:00 2001 From: Sinity Date: Sat, 5 Sep 2026 15:23:28 +0200 Subject: [PATCH 3/4] test: a dispatched child is named by its transcript stem --- tests/unit/sources/test_parsers_claude_code_artifacts.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/unit/sources/test_parsers_claude_code_artifacts.py b/tests/unit/sources/test_parsers_claude_code_artifacts.py index ff895fe2eb..d7af3d622b 100644 --- a/tests/unit/sources/test_parsers_claude_code_artifacts.py +++ b/tests/unit/sources/test_parsers_claude_code_artifacts.py @@ -1070,7 +1070,8 @@ def test_parse_code_ignores_emitting_identity_on_dispatch_progress() -> None: parsed = parse_code(records, "agent-parent-own-agent") [event] = [event for event in parsed.session_events if event.event_type == "claude_delegation_progress"] - assert event.payload["child_provider_id"] == "child-agent" + # The child is named by its transcript stem, the join key the resolver uses. + assert event.payload["child_provider_id"] == "agent-child-agent" assert event.payload["resolution_reason"] is None assert "child_provider_ids" not in event.payload From b424bdd1a1de580abda1e1013de9b415fb61747f Mon Sep 17 00:00:00 2001 From: Sinity Date: Sat, 5 Sep 2026 16:58:09 +0200 Subject: [PATCH 4/4] docs: regenerate the schema disposition for the dispatch-observation columns --- docs/evidence/schema-disposition-2026-08-31.json | 6 +++--- docs/schema-disposition-2026-08-19.md | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/evidence/schema-disposition-2026-08-31.json b/docs/evidence/schema-disposition-2026-08-31.json index 5e342e3bfa..f4f21f0ecd 100644 --- a/docs/evidence/schema-disposition-2026-08-31.json +++ b/docs/evidence/schema-disposition-2026-08-31.json @@ -19354,7 +19354,7 @@ { "campaign_action": "retain and verify the named producer/consumer", "consumer": "index.db production readers", - "definition_sha256": "bf68c416f26bd43418c8f184698717a04674be167ce7021dc8b3863422db4a1e", + "definition_sha256": "a9cb8929e7a41413deac7dc8e25c650974b487963e93c6df91efa14802d5d0d3", "disposition": "KEEP", "evidence": "reachable canonical declaration; live counts are evidence only", "generated_kind": null, @@ -39851,7 +39851,7 @@ "schema_fingerprints": { "audit": "39a2cff1dfbc0d8721f8a29229e6104abbdfa37d6f432d822b412bc580f2b5e9", "embeddings": "822d7ca7d00c658690337f521b0ad03d912903d95435e71a9a5703480e82dc5f", - "index": "a3f4d367d17b1f22d7ba8b35b5a6397a29715d7e97ca0c9aed4b9270322002ac", + "index": "e9060c44f803e3427c973eadca10a12b39fafeb89a5c9ec4c9c4731082ede8eb", "ops": "4443709d4b8fa6706cd0f2e350cb34fe7660c734dcd2407da202a425027e2d80", "source": "b6ea0b8cd6f926106f1de584f5a4c1b114ea6749d7375b256c6f91c85f4d7814", "user": "e87ac873dd6e228784e367ac4252c76bdd18569d9af0dc38bf2ee33128134559" @@ -39859,7 +39859,7 @@ "schema_versions": { "audit": 2, "embeddings": 5, - "index": 93, + "index": 94, "ops": 1, "source": 41, "user": 11 diff --git a/docs/schema-disposition-2026-08-19.md b/docs/schema-disposition-2026-08-19.md index 89a5aaf2ea..063872585f 100644 --- a/docs/schema-disposition-2026-08-19.md +++ b/docs/schema-disposition-2026-08-19.md @@ -948,7 +948,7 @@ Generated by `devtools render schema-disposition` from the canonical SQLite decl | index:column:session_latency_profiles.tool_call_count_by_category_json | column | KEEP | canonical schema owner | index.db canonical writer route | index.db production readers | SELECT COUNT(*) FROM session_latency_profiles | retain and verify the named producer/consumer | none | 5bd2c51099f561917fc6dd963e913c27368728dc178b8fa875a8d7eb733a236a | | index:column:session_latency_profiles.evidence_payload_json | column | KEEP | canonical schema owner | index.db canonical writer route | index.db production readers | SELECT COUNT(*) FROM session_latency_profiles | retain and verify the named producer/consumer | none | a2dadaf710c92a289869e9abf0a57a3badcb705b2fdd100447bfa28d73b999cd | | index:column:session_latency_profiles.search_text | column | KEEP | canonical schema owner | index.db canonical writer route | index.db production readers | SELECT COUNT(*) FROM session_latency_profiles | retain and verify the named producer/consumer | none | 24d3295e25781ccaa1cd9889351fa302ff4d8dd011fb6c17988e60f458a3bf7a | -| index:table:session_links | table | KEEP | canonical schema owner | index.db canonical writer route | index.db production readers | SELECT COUNT(*) FROM session_links | retain and verify the named producer/consumer | none | bf68c416f26bd43418c8f184698717a04674be167ce7021dc8b3863422db4a1e | +| index:table:session_links | table | KEEP | canonical schema owner | index.db canonical writer route | index.db production readers | SELECT COUNT(*) FROM session_links | retain and verify the named producer/consumer | none | a9cb8929e7a41413deac7dc8e25c650974b487963e93c6df91efa14802d5d0d3 | | index:column:session_links.src_session_id | column | KEEP | canonical schema owner | index.db canonical writer route | index.db production readers | SELECT COUNT(*) FROM session_links | retain and verify the named producer/consumer | none | 3bb16e757b931df8ff63a86d54c474423ca2304af7fd9eae70ef0da6bcead52b | | index:column:session_links.dst_origin | column | KEEP | canonical schema owner | index.db canonical writer route | index.db production readers | SELECT COUNT(*) FROM session_links | retain and verify the named producer/consumer | none | 1ea6d5022d11a16d49aa2d5c726c43e301ac2fd56dfd1d1e3b3bb71ee94acd43 | | index:column:session_links.dst_native_id | column | KEEP | canonical schema owner | index.db canonical writer route | index.db production readers | SELECT COUNT(*) FROM session_links | retain and verify the named producer/consumer | none | da9604bfa49c87da3e36e04e7b48a0391089ffab1408b89b282496bfe4fb6249 |