Skip to content

feat(sources): report per-type sidecar coverage for Claude Code parse - #3419

Merged
Sinity merged 3 commits into
masterfrom
feature/ingest/claude-code-sidecar-records
Jul 31, 2026
Merged

feat(sources): report per-type sidecar coverage for Claude Code parse#3419
Sinity merged 3 commits into
masterfrom
feature/ingest/claude-code-sidecar-records

Conversation

@Sinity

@Sinity Sinity commented Jul 31, 2026

Copy link
Copy Markdown
Owner

Summary

Closes the remaining measured gap in polylogue-pbuh ("Claude Code sidecar
records are discarded at parse: 1,172,890 records"): per-type coverage
reporting (AC5), plus honest bd bookkeeping on what was already done, what
this pass did, and what is still open.

Problem

polylogue-pbuh listed 6 acceptance criteria. Auditing the bead against the
current codebase (not re-deriving it from scratch, per the bead's own method
note) found that AC1 ("classify every skip type"), AC2 ("persist typed
evidence for ai-title/agent-name/pr-link/bridge-session/
file-history-snapshot"), and AC3 ("titles/agent names reach read surfaces")
were already fully satisfied by PR #3390 ("index v46 wire-evidence batch"),
already merged to master before this pass started. Re-implementing that work
would have been redundant and risked silently regressing it.

What PR #3390 did NOT do: AC5 (per-type coverage reporting: seen vs. parsed
vs. persisted, so a future silent skip is visible) had no implementation
anywhere in sources/parsers/claude/ or devtools/.

Solution

polylogue/sources/parsers/claude/code_parser.py:

  • _parse_code_records now counts, per skipped sidecar record type, how many
    records were seen vs. how many actually turned into persisted
    evidence (a session_event, a session_ref, a title override, or a
    delegation edge) -- these can genuinely diverge, e.g. a bash_progress
    tick under progress is seen but never persisted (see the classification
    comment above _SKIPPED_SIDECAR_RECORD_TYPES).
  • It also samples record types that reached ordinary message parsing but
    carried no text/blocks and were dropped there (empty_dropped_by_record_type)
    -- the pre-fix(parsers/claude-code): type=progress events persisted as 740k empty tool_result rows (23% of messages table) #1617 failure mode the bead's method note warns against
    repeating by assumption.
  • One bounded claude_parse_coverage session_event is emitted per session
    when either counter is non-empty (no event for the common
    no-sidecar-activity case, so this doesn't bloat every session).
  • session_events.event_type has no CHECK-constrained vocabulary
    (storage/sqlite/archive_tiers/index.py), so this is additive data, not a
    schema change -- no migration, no index bump.
  • _accumulate_delegation_progress now returns whether a record folded into
    a genuine dispatch edge, feeding the persisted counter for progress.

tests/unit/sources/test_claude_code_sidecar_evidence.py:

  • Added a _typed_events() helper so the 9 pre-existing exact-event-list
    assertions look through the new coverage event instead of hard-coding it
    into every one (it's orthogonal to what each of those tests actually pins).
  • Two new tests: test_parse_coverage_event_reports_seen_and_persisted_counts
    (pins the seen/persisted divergence on a real example) and
    test_parse_coverage_event_absent_when_only_ordinary_messages_parsed
    (pins the no-bloat case).

Not done in this pass, recorded honestly on the bead instead of claimed:

  • AC4 (pr-link becomes the session->PR producer, four consumer beads
    unblocked/re-scoped): the producer is real (session_refs table,
    storage/sqlite/queries/session_refs.py) via PR feat(archive): index v46 wire-evidence batch, free-threaded-only runtime, parse-failure recovery #3390, but nothing on the
    CLI/insights/MCP surface reads session_refs yet, so polylogue-cijx.1 and
    its four dependents (212.2/xyel/kph/fs1.4) are not actually
    unblocked. Noted on both polylogue-pbuh and polylogue-cijx.1 with the
    specific gap (reader, not producer). Consumer wiring is insights/CLI/MCP
    territory, outside this pass's declared surface
    (sources/parsers/claude/, assembly_claude_code.py,
    providers/claude_code*.py).
  • AC6 (reprocess existing raws, report before/after UUID-title/PR-link
    census): PR feat(archive): index v46 wire-evidence batch, free-threaded-only runtime, parse-failure recovery #3390's body recorded expected post-rebuild numbers, not an
    actual measured before/after census, and whether the v46 SEMANTIC_REPARSE
    rebuild has actually run against the real corpus since merge is an
    operational fact about the live archive, unverifiable from a sandboxed
    worktree. Recorded on the bead as an explicit open item for whoever has
    archive access next.

No inflation risk: this shape (typed session_events/session_refs, one
row per real fact, plus one bounded coverage event per session) does not add
sessions or messages -- the same producer/consumer shape PR #3390 already
established and that this PR only extends with counters.

Verification

devtools test tests/unit/sources/test_claude_code_sidecar_evidence.py \
  tests/unit/sources/test_parsers_claude_code_artifacts.py \
  tests/unit/sources/test_claude_code_unread_wire_fields.py \
  tests/unit/sources/test_compaction.py \
  tests/unit/sources/test_dispatch_payloads.py \
  tests/unit/sources/test_parsed_session_typed_context.py \
  tests/unit/sources/test_parser_crashlessness.py \
  tests/unit/sources/test_parsers_base.py \
  tests/unit/sources/test_source_laws.py \
  tests/unit/sources/test_tool_result_sidecars.py \
  tests/unit/sources/test_parsers_props.py
# -> 397 passed

devtools verify --quick
# -> exit_code 0 (ruff format/check, mypy --strict, render all --check,
#    layering, closure-matrix, schema-versioning, doc-commands, docs-coverage,
#    test-clock-hygiene, hash-boundary-census all green)

Ref polylogue-pbuh

Sinity and others added 2 commits July 31, 2026 06:24
Problem: polylogue-pbuh AC5 requires coverage reported per sidecar record
type (seen/parsed/persisted) so a future silent skip is visible instead of
requiring another rg-the-corpus audit. AC1/AC2/AC3 were already satisfied by
PR #3390 (index v46 wire-evidence batch, already on master) -- this closes
the AC5 gap that remained after that PR.

What changed: _parse_code_records now counts, per skipped sidecar record
type, how many records were seen vs. how many actually turned into
persisted evidence (a session_event, a session_ref, a title override, or a
delegation edge), plus a sample of record types that reached ordinary
message parsing but produced no text/blocks and were dropped there. One
bounded claude_parse_coverage session_event is emitted per session when
either counter is non-empty (no event for the common no-sidecar-activity
case). session_events.event_type has no CHECK-constrained vocabulary, so
this is additive data, not a schema change.

Existing exact-list session_events assertions across
test_claude_code_sidecar_evidence.py needed a _typed_events() filter to look
through the new coverage event; two new tests pin the coverage event's own
seen/persisted divergence (permission-mode always persists, a bash_progress
tick under progress never does) and its absence on an ordinary session.

Verification:
  devtools test tests/unit/sources/test_claude_code_sidecar_evidence.py tests/unit/sources/test_parsers_claude_code_artifacts.py tests/unit/sources/test_claude_code_unread_wire_fields.py tests/unit/sources/test_compaction.py tests/unit/sources/test_dispatch_payloads.py tests/unit/sources/test_parsed_session_typed_context.py tests/unit/sources/test_parser_crashlessness.py tests/unit/sources/test_parsers_base.py tests/unit/sources/test_source_laws.py tests/unit/sources/test_tool_result_sidecars.py tests/unit/sources/test_parsers_props.py
    -> 397 passed
  devtools verify --quick -> exit 0 (ruff, mypy --strict, render all --check, layering, closure-matrix, schema-versioning all green)

Ref polylogue-pbuh

Co-Authored-By: Claude <noreply@anthropic.com>
Problem: polylogue-pbuh's 6 acceptance criteria were partially satisfied by
prior work (PR #3390) and partially by this pass, but nothing recorded which
were which -- re-closing honestly requires the split to be legible.

What changed: polylogue-pbuh notes now state AC1/AC2/AC3 satisfied by #3390,
AC5 satisfied by this pass (feat commit cd8ad73), and AC4/AC6 as measured
remaining gaps (session_refs has no CLI/insights/MCP reader; no actual
before/after census was run against the live archive from this pass).
polylogue-cijx.1 gets a matching note: its own "producer may already exist"
hunch is confirmed for pr-link specifically, but its four dependents
(212.2/xyel/kph/fs1.4) remain unblocked-in-name-only until a consumer reads
session_refs.

Ref polylogue-pbuh

Co-Authored-By: Claude <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jul 31, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@Sinity, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 3 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: caa629fd-eec9-44c8-b97d-7e228ef986de

📥 Commits

Reviewing files that changed from the base of the PR and between 018c3f4 and 66f9524.

📒 Files selected for processing (2)
  • polylogue/sources/parsers/claude/code_parser.py
  • tests/unit/sources/test_claude_code_sidecar_evidence.py

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 7b1ddcab0f

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +1472 to +1475
if sidecar_seen_counts or empty_drop_counts:
session_events.append(
ParsedSessionEvent(
event_type="claude_parse_coverage",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Merge streaming coverage before emitting the session event

When a streamed aggregate returns to the same session after records from another session, _parse_code_records runs once per contiguous chunk and emits this event each time; merge_parsed_session_chunks concatenates those events, while reconcile_code_session_chunks only coalesces background notifications. The resulting session therefore has multiple claude_parse_coverage events with per-chunk counts rather than the promised single event with per-session totals. Aggregate these payloads during reconciliation or defer emission until chunk merging is complete.

AGENTS.md reference: AGENTS.md:L129-L133

Useful? React with 👍 / 👎.

Comment on lines 1275 to +1276
if not keep_empty_human_turn:
empty_drop_counts[record_type] = empty_drop_counts.get(record_type, 0) + 1

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Bound the set of empty-drop record types

For a large or malformed JSONL stream containing many distinct unknown type strings with empty content, this dictionary grows once per unique attacker/provider-controlled value and is later serialized wholesale into the coverage event. Thus the newly described “bounded sample” breaks the parser's memory-bounded streaming guarantee; cap the number of retained type names and aggregate excess entries into an overflow bucket.

AGENTS.md reference: AGENTS.md:L132-L133

Useful? React with 👍 / 👎.

Sinity added a commit that referenced this pull request Jul 31, 2026
…3423)

## Summary

Closes the three gaps flagged in the `_SKIPPED_SIDECAR_RECORD_TYPES`
disposition audit (polylogue-pbuh follow-up): the `attachment` record
type's 20+ subtypes were collapsed into one `claude_attachment`
session_event; `init`/`mode` were dismissed as transient without a fresh
re-check; and the frozenset's own name ("skipped") had been wrong since
most of its members started persisting as `session_events`.

## Problem

**Gap 1 (attachment collapse).** `code_parser.py`'s own comment conceded
it: "20 distinct attachment.type payloads incl. real files, edited-file
records, diagnostics; per-subtype fidelity split is a follow-up, not
this pass." A full-corpus enumeration (`~/.claude/projects`, ~11,700
session files, 2026-07-31) found **38 distinct subtypes**, 87,539 total
occurrences — a real referenced file's content and a `hook_success` ping
were both landing as `event_type="claude_attachment"`, distinguishable
only by JSON-inspecting `payload["type"]`.

**Gap 2 (init/mode dismissal).** `permission-mode` was kept ("real
operational signal, varies") while its sibling `mode` was dropped as
transient, on the strength of a single earlier pass. That needed
independent re-verification, not a repeated assertion.

**Gap 3 (misleading name).** `_SKIPPED_SIDECAR_RECORD_TYPES` said
"skipped." 13 of its 15 members are persisted as `session_events` today
(polylogue-pbuh). The name described the pre-polylogue-pbuh behavior
only.

## Solution

### Gap 1 — attachment subtype dispatch (`_attachment_sidecar_event`,
`_ATTACHMENT_SUBTYPE_EVENT_TYPES`)

Full per-subtype disposition (live corpus, `~/.claude/projects`,
2026-07-31; counts are subtype occurrences, not full-corpus row totals —
see Verification for the total):

| subtype | count | disposition | event_type |
|---|---:|---|---|
| `file` | 711 | EVIDENCE — real referenced file, full content |
`claude_attachment_file` |
| `edited_text_file` | 2,446 | EVIDENCE — editor-buffer snippet at edit
time | `claude_attachment_edited_file` |
| `nested_memory` | 487 | EVIDENCE — nested CLAUDE.md/memory content |
`claude_attachment_nested_memory` |
| `plan_file_reference` | 43 | EVIDENCE — full plan file content |
`claude_attachment_plan_reference` |
| `compact_file_reference` | 460 | EVIDENCE — bare path reference, no
content | `claude_attachment_file_reference` |
| `directory` | 16 | EVIDENCE — directory listing |
`claude_attachment_directory_listing` |
| `hook_success` | 27,678 | EVIDENCE — hook lifecycle (outcome=success)
| `claude_hook_event` |
| `hook_non_blocking_error` | 558 | EVIDENCE — hook lifecycle
(outcome=non-blocking error) | `claude_hook_event` |
| `hook_blocking_error` | 60 | EVIDENCE — hook lifecycle
(outcome=blocking error) | `claude_hook_event` |
| `hook_cancelled` | 303 | EVIDENCE — hook lifecycle (outcome=cancelled)
| `claude_hook_event` |
| `hook_system_message` | 129 | EVIDENCE — hook lifecycle (system
message) | `claude_hook_event` |
| `hook_additional_context` | 1,003 | EVIDENCE — hook lifecycle
(injected context) | `claude_hook_event` |
| `auto_mode` | 644 | EVIDENCE — agent-mode transition |
`claude_agent_mode_event` |
| `auto_mode_exit` | 130 | EVIDENCE — agent-mode transition |
`claude_agent_mode_event` |
| `plan_mode` | 84 | EVIDENCE — agent-mode transition |
`claude_agent_mode_event` |
| `plan_mode_exit` | 99 | EVIDENCE — agent-mode transition |
`claude_agent_mode_event` |
| `plan_mode_reentry` | 15 | EVIDENCE — agent-mode transition |
`claude_agent_mode_event` |
| `deferred_tools_delta` | 2,856 | EVIDENCE, bounded (names/counts,
drops body text) | `claude_capability_delta` |
| `mcp_instructions_delta` | 1,050 | EVIDENCE, bounded |
`claude_capability_delta` |
| `agent_listing_delta` | 200 | EVIDENCE, bounded |
`claude_capability_delta` |
| `skill_listing` | 2,690 | EVIDENCE, bounded (names extracted, not full
text) | `claude_capability_snapshot` |
| `invoked_skills` | 117 | EVIDENCE, bounded |
`claude_capability_snapshot` |
| `output_style` | 10,055 | EVIDENCE — real operational signal (style
mode) | `claude_output_style` |
| `command_permissions` | 570 | EVIDENCE — real operational signal
(allowed tools) | `claude_command_permissions` |
| `queued_command` | 7,387 | EVIDENCE — same entity as top-level
`queue-operation`, reused type | `claude_queue_operation` |
| `task_status` | 80 | EVIDENCE — polled background task status |
`claude_task_status` |
| `task_reminder` | 19,751 | EVIDENCE — 37% non-empty, carries real todo
id/subject/status/blocks/blockedBy | `claude_task_reminder` |
| `diagnostics` | 1,583 | EVIDENCE, bounded (per-file finding counts,
not full LSP messages) | `claude_diagnostics` |
| `goal_status` | 367 | EVIDENCE — sentinel goal condition |
`claude_agent_goal_status` |
| `date_change` | 163 | EVIDENCE — session crossed a calendar day |
`claude_date_change` |
| `read_truncation_notice` | 97 | EVIDENCE — a Read tool output was
truncated | `claude_read_truncation_notice` |
| `ultrathink_effort` | 11 | EVIDENCE — explicit reasoning-effort signal
| `claude_agent_effort` |
| `structured_output` | 1 | EVIDENCE — rare, potentially meaningful |
`claude_structured_output` |
| `max_turns_reached` | 1 | EVIDENCE — session hit a turn budget |
`claude_max_turns_reached` |
| `total_tokens_reminder` | 5,677 | **TRANSIENT** — constant string
`"<total_tokens>Infinite tokens left</total_tokens>"` in every sample |
— (dropped) |
| `todo_reminder` | 5 | **TRANSIENT** — always `{"content": [],
"itemCount": 0}` in every occurrence observed | — (dropped) |
| `context_tip` | 11 | **TRANSIENT** — CLI feature-adoption UI hints,
not session evidence | — (dropped) |
| `companion_intro` | 1 | **TRANSIENT** — novelty/branding record (pet
companion name) | — (dropped) |

Grouping rule: subtypes reporting the *same real-world entity* (a hook
firing, an agent-mode transition, a capability-surface delta) share one
`event_type` distinguished by payload fields — not 38 near-empty types.
Real file/reference content gets its own `event_type` per subtype since
each is a structurally distinct artifact.

An unrecognized future subtype (39th+) routes to
`claude_attachment_unclassified` rather than silently merging into a
known bucket — FAIL LOUD, independent of whether #3419's
`claude_parse_coverage` event lands first.

Deliberately **not** wired into `ParsedAttachment`/the blob store this
pass: `_acquire_attachment_blob` in
`storage/sqlite/archive_tiers/write.py` raises on `inline_bytes` unless
routed through an archive-owned blob publisher, and `write.py` is out of
this lane's scope (owned by #3419's neighboring work). Real file content
(`file`, `edited_text_file`, `nested_memory`, `plan_file_reference`) is
kept at full fidelity as `session_events` payloads instead — promoting
these to first-class `attachments` rows is a bigger, separately-scoped
change through the blob-publisher path.

Capability-delta/skill-listing/diagnostics payloads are bounded
(names/counts, not full injected instruction text or full LSP message
text) — same precedent as the existing `_tool_execution_result_payload`
(hunk counts, not full diffs).

### Gap 2 — init/mode re-verification

Re-ran the corpus scan independently (not just re-asserting the prior
comment): `init` — 0 occurrences (confirmed twice now); `mode` —
21,545/21,545 (100%) literal `"normal"` (up from 20,779 total on the
prior pass, same zero-variance result). Disposition unchanged; the
comment now cites two independent measurements instead of one, and
explicitly contrasts with `permission-mode` (5 distinct values, kept).

### Gap 3 — rename

`_SKIPPED_SIDECAR_RECORD_TYPES` → `_NON_MESSAGE_SIDECAR_RECORD_TYPES`.
Hard rename, no compat alias (repo policy). Updated the one other
reference (`origin_specs.py`'s admission-declaration comment) and an
illustrative event-type mention in `api/archive.py`'s docstring.

## Verification

- `devtools test tests/unit/sources/test_claude_code_sidecar_evidence.py
tests/unit/sources/test_parsers_claude_code_artifacts.py
tests/unit/sources/test_assembly_claude_code_history.py
tests/unit/sources/test_source_laws.py` — **204 passed**.
- `devtools verify --quick` (ruff format/check, mypy --strict, render
all --check,
topology/layering/closure-matrix/schema-versioning/schema-promotion
gates) — exit 0.
- `ruff format --check` / `ruff check` / `mypy` on the touched file
individually — clean.
- **Projected row counts** (no session inflation — attachment records
only, never new sessions): total attachment `session_events` emitted
goes from 87,539 (all `event_type="claude_attachment"`) to **81,845
across ~22 event_types**, i.e. a **net reduction** of 5,694 rows (the 4
confirmed-transient subtypes now correctly emit nothing instead of
noise). `init`/`mode` disposition is unchanged (still 0 / still dropped)
so no additional rows from Gap 2.

## Out of scope (explicitly, per instructions)

- The `progress` record type's disposition (only `agent_progress`
persists; the other six subtypes are superseded streaming ticks) —
untouched, per instructions.
- Promoting real-file-content attachment subtypes (`file`,
`edited_text_file`, `nested_memory`, `plan_file_reference`) to
first-class `ParsedAttachment`/blob-store rows — needs the archive-owned
blob-publisher wiring in `storage/sqlite/archive_tiers/write.py`, which
this lane was told to avoid. Filing a follow-up bead is reasonable
next-session work if the operator wants that fidelity bump.
- `#3419`'s `claude_parse_coverage` seen/persisted counter — independent
PR, not merged as of this branch; this PR's fail-loud mechanism
(`claude_attachment_unclassified`) works standalone and composes cleanly
if #3419 lands first or second.

---------

Co-authored-by: Claude <noreply@anthropic.com>
# Conflicts:
#	.beads/issues.jsonl
#	polylogue/sources/parsers/claude/code_parser.py
@Sinity
Sinity merged commit 3ed2124 into master Jul 31, 2026
2 of 3 checks passed
@Sinity
Sinity deleted the feature/ingest/claude-code-sidecar-records branch July 31, 2026 08:44
Sinity added a commit that referenced this pull request Jul 31, 2026
…nts (#3437)

## Summary

Restores master to green. Two independently-correct PRs — #3419
(per-type sidecar coverage) and #3423 (attachment sidecar split) —
produced a broken combination, and the merge that joined them was
verified against a narrower test selection than the change warranted.

## Problem

`devtools test -k "sidecar or code_parser"` on master: **7 failed, 138
passed**.

All seven are `test_attachment_*` in
`tests/unit/sources/test_claude_code_sidecar_evidence.py`. They assert
exact equality on `parsed.session_events`; #3419 unconditionally appends
a `claude_parse_coverage` event, so every expected list gained an
unexpected member:

```
Left contains one more item: ('claude_parse_coverage',
  {'sidecar_seen': {'attachment': 1}, 'sidecar_persisted': {}, ...})
```

That payload also exposes a real defect rather than only a
test-reconciliation problem: `sidecar_persisted` is empty while
`sidecar_seen` counts the record. The attachment branch appends a
`session_event` but never sets `persisted_this_record`, so per-type
coverage understated persistence for every attachment record — the exact
metric #3419 exists to report.

## Solution

- `polylogue/sources/parsers/claude/code_parser.py` — the attachment
branch sets `persisted_this_record = True` when it emits an event,
matching every sibling branch in the same dispatch.
- `tests/unit/sources/test_claude_code_sidecar_evidence.py` — the seven
exact-equality assertions exclude `claude_parse_coverage`. It is a
parse-level report orthogonal to the attachment evidence each test is
about; filtering keeps each test pinned to its own subject rather than
loosening the assertion.

## Verification

```
devtools test -k "sidecar or code_parser"
  before:  7 failed, 138 passed
  after:       145 passed
```

`devtools test tests/unit/sources/test_claude_code_sidecar_evidence.py`
→ 30 passed.

## Note

The conflict resolution that produced this was verified with `devtools
test tests/unit/sources/test_assembly_claude_code_history.py` (14
passed) — a file that does not exercise the attachment path. A
cross-feature merge deserves the affected-area selection, not the file
the conflict happened to sit in.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Sinity added a commit that referenced this pull request Jul 31, 2026
…eation (#3497)

## Summary

Two-part fix for the classification-gate correctness problem: (1)
repairs a genuine regression introduced by the polylogue-9ykn
content-classification gate (PR #3428) that refused genuine Claude Code
session records, and (2) implements the general "session requires
positive conversational evidence" invariant at the parse/ingest
chokepoint, with a read-only reconciliation of the 5,257 zero-message
sessions currently in the live archive.

## Problem

**polylogue-6mpy** (failing test on clean master):
`tests/unit/sources/test_revision_backfill.py::test_parse_one_still_replays_real_claude_code_sessions_with_no_path_rule`
failed with `assert 0 == 1`. Root cause: `classify_artifact`'s content
gate (added for polylogue-9ykn, PR #3428) consulted
`classify_artifact_path` first and returned unconditionally — any source
path containing an `analysis/` directory segment was refused as a
self-generated side-output artifact **regardless of record content**, so
a genuine Claude Code session record (`role=user`, real `sessionId`,
real timestamp) sitting under such a path was silently dropped on
replay.

**polylogue-9ykn** (P0): the ingest path's default disposition was "this
is a session" — anything not positively recognised as something else
still became one. Measured against the live archive (2026-07-31,
read-only queries against `/realm/db/polylogue/{index,source}.db`):

```sql
SELECT COUNT(*) FROM sessions;                                   -- 23,496
SELECT origin, COUNT(*) FROM sessions WHERE message_count=0
  GROUP BY origin ORDER BY 2 DESC;
  -- claude-code-session  5,193
  -- claude-ai-export         47
  -- codex-session            17
  -- total: 5,257 (22.4%)

-- C4 overlap (created_at_ms NULL population):
SELECT COUNT(*) FROM sessions WHERE created_at_ms IS NULL;       -- 5,383
SELECT COUNT(*) FROM sessions
  WHERE message_count=0 AND created_at_ms IS NULL;                -- 5,192
```

5,192 of the 5,193 empty `claude-code-session` rows (and 5,192/5,193
overall) also carry `created_at_ms IS NULL` — this **is** the C4
population (the one row with a non-NULL `created_at_ms` is
`problems_index`, a self-generated analysis artifact with its own real
acquisition timestamp).

## Solution

### polylogue-6mpy

`polylogue/archive/artifact_taxonomy/runtime.py`: split
`classify_artifact_path` into a weak, content-blind
`analysis/`-directory heuristic
(`_self_generated_artifact_dir_classification`) and the strong,
definitive path rules (`_classify_artifact_path_strong`: OriginSpec
rules, known sidecar filenames, Hermes/Antigravity path markers).
`classify_artifact` (content-aware) now checks strong path rules first
(unconditional, unchanged), then content classification, and only falls
back to the weak `analysis/` heuristic when content shows no positive
session evidence — so a relationship-index pointer record (no envelope
markers) is still refused, but a genuine session record wins on content
regardless of directory naming.

### polylogue-9ykn

`polylogue/sources/dispatch.py` gains
`require_positive_conversational_evidence()` +
`message_carries_authored_content()`: a session is kept only if at least
one message has real text or a content block.

**Deliberately not folded into `parse_payload`/`parse_stream_payload`
themselves** — those two functions are pure provider-routing dispatch,
pinned by a large "law" test surface (`test_source_laws.py` and friends)
that monkeypatches provider parsers with zero-message stubs specifically
to test *routing* independent of content. Folding the gate in there
broke 20+ of those tests. Instead every real production write path
applies the filter explicitly after calling
`parse_payload`/`parse_stream_payload`:

- `sources/live/batch.py`'s full-ingest loop (reuses the *existing*
`mark_raw_parse_failed` "recorded, bounded failure" mechanism — a
filtered-to-empty result trips the same `if not sessions:` branch a
genuinely-unparseable payload already hits)
- `pipeline/services/ingest_worker.py`'s `_parse_plan_sessions`
(subprocess decode/parse worker; reuses the existing `"parse: session
artifact produced no materializable sessions"` error path)
- `sources/live/append_ingest.py` (incremental append)
- `sources/revision_backfill.py`'s `_parse_one`/`_parse_stream` (offline
replay/rebuild, alongside its own polylogue-6mpy path/shape gate —
catches the sibling case where the shape is recognised but parsed
content still carries no message)

Checking message *content*, not just message *count*, closes a sibling
gap found while testing: an unrecognized single-record document with no
known provider markers fell through Claude Code's generic
single-document lowering into a one-message session whose sole message
had empty text and no blocks — structurally "has a message" but zero
actual evidence.

### Reconciliation (AC b) — read-only, `?mode=ro`

Joining `index.db.sessions` to `source.db.raw_sessions` via `raw_id`:

| Class | Count | Disposition |
|---|---|---|
| `agent-*.meta` sidecars | 4,945 | Artifact — already refused by
`classify_artifact`'s path rule (pre-dates this PR); pending purge |
| JSONL files with only non-conversational envelope records
(`file-history-snapshot`/`progress`/`bridge-session`/`custom-title`/`agent-name`)
| 228+ | Artifact — genuine per-session JSONL files whose entire content
is Claude Code checkpoint/protocol records, never a real message.
**Eliminated at source by this PR's gate.** |
| `tool-results/*.json` sidecars mis-dispatched as sessions | 3 |
Artifact — wrong-provider-dispatch of a single-JSON-object sidecar file
as if it were a session-shaped stream |
| `.gemini/` paths misdetected as `claude-code-session` | 2 | Artifact |
| `claude-ai-export` conversations with a real title but `chat_messages:
[]` | 47 | Content-legitimate export entity, but carries no conversation
— refused by this PR's gate too |
| `codex-session` empty rows | 17 | Mixed: some are zero-byte
scan/write-race captures (now refused instead of silently materializing,
see `test_full_ingest_empty_jsonl_is_not_misclassified_as_truncated`); a
few (996KB/1.4MB blobs) look like a **separate, unrelated Codex
message-extraction defect** — flagged as a residual, not fixed here (out
of scope; see Residuals) |

**On the 832 ne6k-retained rows**: polylogue-ne6k's own 2026-07-31
investigation (same day) already superseded the original "832
intentionally-retained genuinely-empty sessions" framing — its corrected
note states "the 832 the hook-inflation postmortem retained were
retained precisely BECAUSE the blanket predicate could not tell them
apart, not because they were verified worth keeping" and "no 'genuinely
empty session' is a legitimate construct." This PR's gate is therefore
consistent with that corrected finding — it does not carve out a
legitimacy exception for any zero-message row.

**AC (c) — eliminated at source**: the gate above stops all classes
above from ever entering the index again, in one place, for every
ingest/replay route. Existing phantom rows in the live archive are left
for the already-planned v46→v50 index rebuild (polylogue-x1gd) per this
lane's non-goals — **no live-archive writes were made by this PR**.

## Anti-vacuity statement

Production callers exercising the new surface:
`polylogue/sources/live/batch.py:2086` (daemon full-ingest, in-process),
`polylogue/pipeline/services/ingest_worker.py:_parse_plan_sessions`
(subprocess decode/parse worker),
`polylogue/sources/live/append_ingest.py` (incremental append),
`polylogue/sources/revision_backfill.py:_parse_one`/`_parse_stream`
(offline replay/rebuild). Mutation that makes the tests fail: removing
the message-content check from
`require_positive_conversational_evidence` (or reverting it to a
message-*count* check) makes
`test_parse_payload_generic_unrecognized_record_shape_manufactures_only_a_content_free_message`
fail; removing the `require_positive_conversational_evidence` call from
any of the four call sites makes that call site's corresponding fixture
test in
`test_live_batch_support.py`/`test_resilience.py`/`test_revision_backfill.py`
fail (each asserts the specific refused/empty outcome, not merely "no
crash").

## AC matrix

| AC | Status |
|---|---|
| 6mpy: content evidence overrides weak path heuristic; test passes |
Satisfied |
| 6mpy: relationship-index / evidence-free shape stays refused |
Satisfied (regression-pinned,
`test_parse_one_refuses_non_conversational_content_with_no_path_rule`) |
| 9ykn (a): default disposition for unrecognised record is
refusal-with-reason, regression-pinned, no session created | Satisfied —
3 new tests in `test_dispatch_payloads.py` pin
`require_positive_conversational_evidence` against a session-meta-only
stream, a `chat_messages: []` export conversation, and the
unrecognized-shape single-message case |
| 9ykn (b): 5,255/5,257 empty-session population explained (intentional
vs artifact) | Satisfied — see reconciliation table above; no
intentional/legitimate subset remains per ne6k's corrected finding |
| 9ykn (b): C4 `created_at_ms` NULL overlap reported with proving query
| Satisfied — 5,192/5,193 overlap, query above |
| 9ykn (c): artifact class eliminated at ingest source (code fix) |
Satisfied for the classes measured above; existing rows deferred to the
v46→v50 rebuild (non-goal: no live writes) |
| 9ykn: regression test pins unrecognized record ≠ session | Satisfied |

## Verification

```
python -m devtools test tests/unit/sources/test_revision_backfill.py tests/unit/sources/test_artifact_taxonomy.py
  -> 65 passed

python -m devtools test tests/infra/pipeline_roundtrip.py tests/unit/sources/test_dispatch_payloads.py \
  tests/unit/sources/test_dispatch_ordering.py tests/unit/sources/test_source_laws.py \
  tests/unit/sources/test_revision_backfill.py tests/unit/sources/test_artifact_taxonomy.py \
  tests/unit/sources/test_live_batch_support.py tests/unit/pipeline/test_resilience.py \
  tests/unit/daemon/test_ingest_worker_handoff.py tests/unit/pipeline/test_ingest_worker_assembly.py
  -> 334 passed, 5 failed

python -m devtools verify --quick
  -> exit 0 (ruff format/check, mypy --strict, render all --check, topology,
     layering, closure-matrix, schema/manifest/policy lab checks all pass)
```

The 5 remaining failures were each reproduced against a clean-master
baseline worktree (`git worktree add --detach <tmp> 4477b96`, the
commit immediately before this branch) and confirmed to fail identically
there — pre-existing, unrelated to this change:
-
`test_dispatch_payloads.py::test_parse_stream_payload_codex_long_rollout_with_repeated_session_meta_yields_messages`
-
`test_pipeline/test_resilience.py::test_validation_law_matches_mode_and_payload_contract`
-
`test_live_batch_support.py::test_append_multi_session_payload_is_rejected_before_index_write`
-
`test_live_batch_support.py::test_full_ingest_skips_durably_excised_content_without_aborting_batch`
-
`test_live_batch_support.py::test_full_ingest_writes_archive_with_route_observability`

(A wider batch also confirmed as pre-existing this way:
`test_web_reader.py::test_archive_filter_kwargs_cover_every_storage_lowerable_spec_field`,
`test_synthetic_semantics.py`'s two antigravity parametrizations,
`test_claude_code_normalization_laws.py::test_family_fixture_detector_and_streaming_paths_preserve_one_normalized_identity`.)

`devtools verify` (full testmon-affected run) was not run: this fresh
worktree has no seeded testmon database (`.cache/testmon/testmondata`
absent) and `--seed-testmon` would run the full non-integration suite,
which is out of the narrow-node-reproduction budget for this lane. The
targeted file list above plus `--quick`'s static/generated gates are the
verification evidence for this PR.

## Residuals / follow-ups

- A handful of `codex-session` zero-message rows (2 of the 17) have
large (996KB/1.4MB) raw blobs that look like genuine conversations whose
messages failed to extract for an unrelated reason — flagged for a
follow-up bead, not investigated further here (out of scope: this lane
is about the classification/evidence gate, not a Codex parser defect).
- Other in-flight worktrees in this repo have branches named
`feature/sources/positive-session-classification` and
`feature/sources/self-generated-analysis-artifact-refusal` (no open PRs
found for either at push time) — their scope may overlap this PR's;
worth a coordinator check before merge.
- `raw_sessions.detection_warnings_json` (the durable per-raw-revision
warning sink) is not fed by `require_positive_conversational_evidence`'s
refusal — the filter runs after a `ParsedSession` already exists in
memory, with no natural session-scoped event sink to write to since no
session is created. The refusal is currently recorded via
`logger.warning` plus the existing `mark_raw_parse_failed`/`"produced no
materializable sessions"` bounded-error paths at each call site. Wiring
a `claude_parse_coverage`-family detection event for this specific
refusal reason (PR #3419) would need a raw-revision-scoped (not
session-scoped) sink and is left as a follow-up rather than invented ad
hoc here.

Ref polylogue-6mpy, polylogue-9ykn

---------

Co-authored-by: Claude <noreply@anthropic.com>
Sinity added a commit that referenced this pull request Aug 2, 2026
…ad model (#3543)

## Summary

Ingests Claude Code's live plan-snapshot directory
(`~/.claude/todos/*.json`) into the archive end-to-end: raw admission
with provenance, a session-linked read model over every retained
revision, and a registered plan-completion metric.
`~/.claude/file-history/` is deferred as a scoped follow-up (see below).

## Problem

polylogue-t0p: Claude Code writes several artifacts beyond the session
JSONL transcript that answer questions the transcript alone cannot, and
the harness prunes some of them on its own schedule -- unread state is
eventually lost, not merely delayed.
`~/.claude/todos/<session-id>[-agent-<agent-id>].json` holds the agent's
current TODO/plan list (task content/status/priority/id), overwritten
wholesale on every `TodoWrite` call. Nothing in the archive captured
this before.

## Solution

- **Admission**: a new `todo_snapshot` `OriginArtifactRule` in
`polylogue/sources/origin_specs.py` classifies `todos/*.json` as a
fact-tier Claude Code artifact, the same admission shape already used
for `workflow_run_snapshot`/`agent_sidecar_meta` (PR #3419/#3437/#3448
sidecar-join precedent this bead pointed at). Fidelity is declared
explicitly: preserved -- every item's content/status/priority/id and the
agent's own list order; lost -- intermediate status transitions between
two watcher-observed snapshots (each write is a full overwrite, no
in-file timestamp), and anything pruned before observation.
- **Parser**: `polylogue/sources/parsers/claude/todos.py` parses the
bare JSON array and recovers `session_id`/`agent_id` from the filename
(`<uuid>[-agent-<uuid>].json`, verified against the real on-disk shape
under `~/.claude/todos/`).
- **Watched root**: `~/.claude/todos` is a new configured/watched source
(`polylogue/config.py`, `polylogue/paths/_roots.py`,
`polylogue/sources/live/watcher.py` -- a second `WatchSource` sibling to
`claude-code`, same pattern as the existing `codex-state` root), aliased
to the `claude-code` provider (`polylogue/core/provider_identity.py`) so
the existing `_admit_non_session_origin_artifacts` admission path and
`classify_artifact_path` pick it up with zero bespoke wiring --
confirmed by tracing that gate's `Provider.from_string(source.name) is
Provider.CLAUDE_CODE` filter.
- **Read model**: `polylogue/insights/claude_todo_projection.py` is a
storage-free, session-linked projection (same justification as
`insights/run_projection.py`) over every retained `todo_snapshot` raw
revision in `raw_sessions` (not just the current-pointer `raw_artifacts`
row, which would collapse history to the latest snapshot) -- per-session
plan state, latest completion rate, and per-item status-transition
history across observed snapshots.
- **Measure registration**: `plan_completion_rate` registered in
`insights/measurement/registered_metrics.py`'s `DEFAULT_METRIC_REGISTRY`
(rxdo.9.1 identity layer), the same bounded slice `session_cost_usd`
already uses -- reachable via MCP
`get(ref="metric:plan_completion_rate")`. polylogue-9l5.7's full
statistics/composition registry (checked: still open, `MeasureSpec`
unimplemented) is *not* duplicated here; this registers identity only,
same limitation `session_cost_usd` already documents.

## Deferred: `~/.claude/file-history/`

Investigated the real on-disk shape to save the follow-up lane research
time:

- Each session has a directory
`~/.claude/file-history/<session-id>/<hash>@v<N>` holding **raw pre-edit
file bytes with no wrapper** (no path, no timestamp inside the file
itself).
- The mapping from `<hash>@v<N>` back to a real file path lives **inside
the session's own JSONL transcript**, in a `type:
"file-history-snapshot"` record's `snapshot.trackedFileBackups: {<path>:
{backupFileName, version, backupTime}}` map (plus `type:
"file-history-delta"` records for incremental updates).
- This is **already parsed today** as an evidence-only session event
(`code_parser.py`'s `_sidecar_evidence_payload` for
`file-history-snapshot`/`file-history-delta`, `#2qx.4`) -- it records
the file list and backup pointers, but never acquires the actual
snapshot bytes at `~/.claude/file-history/`.
- Follow-up scope: join the already-parsed `trackedFileBackups` pointers
to the sibling `~/.claude/file-history/<session-id>/` directory,
content-address-acquire each snapshot as a blob keyed to (session, path,
version), and apply the same privacy/redaction discipline
`_acquire_attachment_blob` uses for attachments (these are literal
pre-edit file contents from real projects). This is a distinct
acquisition shape from `todo_snapshot` (cross-referencing two
directories, not a single self-contained file) and was intentionally
left out of this PR per the bead's own note that if full scope is too
large for one PR, todos should land alone.

Lower-priority artifacts the bead also names (`history.jsonl`,
`history-summaries/`, `debug/`, `mcp-logs/`, `ide/` locks, `jobs/`,
`ccusage`/stats caches) were not investigated this session; still open.

## Verification

- `devtools test tests/unit/sources/test_origin_specs.py
tests/unit/sources/test_parsers_claude_todos.py
tests/unit/insights/test_claude_todo_projection.py
tests/unit/core/test_config.py tests/unit/sources/test_live_watcher.py`
-> 219 passed. (One pre-existing failure,
`test_live_batch_processor_records_durable_attempt`, reproduces
identically with this diff stashed on this same checkout -- unrelated
codex-path assertion, not touched by this change.)
- `mypy polylogue` -> `Success: no issues found in 1113 source files`.
- `ruff check` / `ruff format --check` -> clean.
- `devtools render all --check` -> OK (topology projection regenerated
for the two new modules, `devtools render topology-projection`).
- `devtools verify --quick` -> exit 0 (includes a cherry-picked,
already-vetted-elsewhere ack commit for an unrelated pre-existing
`classifier-fingerprints` gate failure from PR #3537, tracked separately
at issue #3540 -- confirmed via `git stash` that this failure predates
and is independent of this branch's changes).

Anti-vacuity: removing the `todo_snapshot` `OriginArtifactRule` (or
narrowing its path pattern) makes
`artifact_rule_for_path`/`classify_artifact_path` return `None` for a
real todos path, which
`test_origin_spec_admits_todos_directory_artifact_as_fact_tier` and the
end-to-end
`test_todos_admitted_and_materialized_into_session_linked_plan_states`
(via `parse_sources_archive`) both catch. Removing the
`claude-code-todos` -> `claude-code` provider alias makes
`_admit_non_session_origin_artifacts`'s
`Provider.from_string(source.name) is Provider.CLAUDE_CODE` gate skip
the source entirely, caught by
`test_todos_source_name_resolves_to_claude_code_provider` and the same
end-to-end test (would return zero plan states).

Ref polylogue-t0p

Co-Authored-By: Claude <noreply@anthropic.com>

Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant