Skip to content

Fix #1464: Claude Code sessions recorded twice since OTEL attach: transcript backfill and OTEL lanes never dedupe - #1468

Merged
philcunliffe merged 2 commits into
masterfrom
fix/issue-1464
Sep 8, 2026
Merged

Fix #1464: Claude Code sessions recorded twice since OTEL attach: transcript backfill and OTEL lanes never dedupe#1468
philcunliffe merged 2 commits into
masterfrom
fix/issue-1464

Conversation

@philcunliffe

@philcunliffe philcunliffe commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

Feature or issue

Since the OTEL attach every Claude Code session is written to ai_gateway_messages twice: once by the transcript backfill sweep (conversation_source = 'claude', keyed by the transcript uuid) and once by the OTEL listener (claude_code). Content events carry message.uuid, so their rows already share identity with the sweep's, but the blocks events do not carry (tool_use, tool_result, thinking) reach the OTEL lane only through a spooled body, which has no uuid anywhere, so the gateway synthesized a content hash for them. The part_id dedupe LLP 0262 relies on never fired: on the reporting machine 29 of 50 sessions were dual-captured, 1,155 tool calls and 571k output tokens counted twice, inflating every report over that window by about a third.

Solution

  • The OTEL projection now stamps the LLP 0027 content match-key on the messages a spooled body produces, exactly as the proxy projector already does for its fallback rows. Flush-time settlement upgrades those rows to the transcript line's uuid, so both lanes land part_id = <uuid>#<part_index> and the dataset's dedupe collapses the overlap whichever lane wrote first. stampQuerySource generalizes to foldClaudeAttributes so the key merges beside a row's existing usage block instead of clobbering it.
  • LLP 0389 records that a body-derived row is provisional; LLP 0254 gets an Extended-by forward ref narrowing "final when written" to the rows a content event produces.
  • test/plugins/claude-otel-body-overlap.test.js runs the real backfill provider over an on-disk transcript and the real listener projection over a spooled response and request body for the same session. Before the fix the tool call landed as 3a39d0e53ae68dc0#0 against the sweep's 5233b3fa-...#0 and the duplicate survived the dedupe carrying a second copy of the turn's usage; after it, both lanes agree and a session the sweep already stored adds no body-derived row. npm test and npm run typecheck are otherwise unchanged from the base (3 pre-existing dependency-pin test failures and 1 pre-existing squirreling type error reproduce on an unmodified checkout in the same environment).
  • Rows already written carry no match-key, so nothing can re-match them: the duplicates in an existing cache stay, and a report over that window has to collapse on session_id plus tool_call_id or read a single conversation_source. LLP 0389 states that consequence.

Code: +29 / -12 lines

Fixes #1464

…#1464)

On an OTEL-attached machine both the telemetry listener and the transcript
backfill sweep capture the same session. Content events carry `message.uuid`,
so their rows already share identity with the sweep's. The blocks events do not
carry - tool_use, tool_result, thinking - reach the OTEL lane only through a
spooled body, which has no uuid, so the gateway synthesized a content hash for
them while the sweep wrote the same block under the transcript line's uuid. The
`part_id` dedupe LLP 0262 relies on never fired, and every tool call and its
token usage was stored twice.

Stamp the LLP 0027 match-key on the messages a spooled body produces, as the
proxy projector already does for its fallback rows, so flush-time settlement
upgrades them to the transcript uuid and the overlap collapses whichever lane
wrote first.

LLP 0389 records that a body-derived row is provisional and narrows LLP 0254 to
the rows a content event produces.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@philcunliffe

philcunliffe commented Sep 8, 2026

Copy link
Copy Markdown
Contributor Author

Superseded record. This was the first posting of the review of f83a4b7d. It was replaced by the corrected record posted at 04:17 (#1468 (comment)), which is the round record for this head. The text below is kept because it carries the probe results that refuted two findings raised by a parallel reviewer, which the replacement does not repeat. Its neutral-review marker has been demoted so one round is not counted twice.

neutral review round 1 - findings

Reviewed f83a4b7d3521920cb54762444b4a9f3d06a0437e against origin/master (66c1f9a4) at high effort, in a detached worktree.

One MEDIUM finding, not fixed here and not a merge blocker. Nothing pushed.

Record correction. An earlier version of this comment concluded "clean". A parallel reviewer then raised three findings; probing them turned up one real problem I had missed (F1 below), and refuted the other two as stated. This comment is the corrected round record and supersedes the earlier text.

Verdict

The fix does what it claims: the identity convergence is correct, the regression test is genuinely load-bearing, the LLP work follows the repo's discipline, and there is no CPU or memory concern. It eliminates the duplicated tool-call rows in every ordering.

But collapsing those rows exposes a pre-existing disagreement between the two lanes about which row carries a turn's usage, and that disagreement can now zero out a turn's tokens where before it could only double them. That is worth recording against a PR whose purpose is to make token accounting correct. It is narrow, it is not introduced by this diff, and it should be a follow-up rather than scope creep here.


F1 (MEDIUM, not fixed) - the two lanes put usage on different rows, and this PR makes that consequential

hypaware-core/plugins-workspace/claude/src/telemetry/bodies.js:370 and hypaware-core/plugins-workspace/claude/src/backfill.js:535

LLP 0035#one-carrier fixes the carrier as "the last assistant row of the response (the terminal output item, a tool_use on tool-calling turns, else the final text)". The sweep and the proxy obey it. The OTEL lane does not: responseGapMessages parks usage on the last gap block only when the response has no text block, so on a [text, tool_use] response - the single most common assistant turn shape - usage rides the text row instead.

Probed on the head with a realistic transcript (usage duplicated onto every block line, which is what backfill.js:517-518 records real Claude Code doing):

--- SWEEP (transcript backfill) ---
text       <U_TEXT>#0    usage=null
tool_call  <U_TOOL>#0    usage={"input_tokens":12,"output_tokens":34}

--- OTEL (event + body lanes, pre-settle) ---
text       <U_TEXT>#0    usage={"input_tokens":12,"output_tokens":34}
tool_call  efdeffdf133aff9a#0    usage=null

Before this PR the tool_call rows had different ids, so the sweep's copy (with usage) always survived: a turn's usage totalled 1x or 2x, never zero. After this PR both rows collapse, and each surviving row's attributes come from whichever lane committed it first:

text row from tool row from usage counted
OTEL OTEL 1x (correct)
sweep sweep 1x (correct)
OTEL sweep 2x (the pre-existing over-count, surviving)
sweep OTEL 0x (new)

The same-lane rows are the common case, so the dominant outcome is the correct one and the PR is a clear net improvement. The 0x row needs a mixed commit order, which needs the cron sweep (backfill.sweep_cron) to observe a partial transcript - the text line written, the tool_use line not yet - so it commits the text row while the OTEL lane later wins the tool row. Narrow, but the sweep is cron-driven and can fire mid-turn.

Why not fixed here. The divergence lives entirely in code this diff does not touch, and it is a pre-existing violation of an Accepted LLP. Aligning the OTEL lane to one-carrier means moving usage off the assistant_response row onto the response's last block, which changes usage placement on a shipped lane and has to coordinate with messageFromEvent's usageByRequestId claim. That is its own decision with its own doc, not a rider on a part_id identity fix. Under CLAUDE.md ("land the small one and defer the rest") the right disposition is a tracked follow-up: concrete, consequential, and clearly outside this task. I have not filed it, since a review rung should not mint repo artifacts beyond this record - please raise it.


Two findings raised by a parallel reviewer that do not survive probing

Claimed: a new part_id collision drops the assistant's answer text when assistant_response's message.uuid names a non-text line (the thinking line of a thinking+text response), because the body-derived reasoning row now settles onto that same uuid. Refuted as attributed to this PR. I ran the premise with the body lane entirely absent - the event lane and the sweep only, i.e. unmodified master:

SWEEP rows:                    text <P>#0 | reasoning <U_THINK>#0 | text <U_TEXT>#0
OTEL EVENT-LANE rows only:     text <P>#0 | text <U_THINK>#0
clash: <U_THINK>#0 - event lane says text, sweep says reasoning

If assistant_response's uuid really named a non-text line, the two already-shipped producers would already collide on it today, and the event row would already be inheriting the wrong parent_uuid and provenance. So the premise, if ever true, is a pre-existing event-lane identity defect that this PR neither creates nor worsens in kind. The underlying structural remark is fair and worth keeping on the record - part_type is not part of the dedupe key, so any part_id collision drops content silently - but it is not a defect of this diff, and claude_otel_shape_check is precisely the acceptance gate that would catch the upstream shape it depends on.

Claimed: a tool-only turn's usage is lost when the transcript tool_use line has no message.usage. Refuted at the premise. backfill.js:517-518 records, from real capture, that "usage is a response-level (per API message) figure that Claude Code duplicates onto every block line of an assistant turn" - that duplication is the entire reason the one-carrier rule exists. A transcript block line without message.usage is not a shape Claude Code produces, so that probe's fixture is synthetic. The valid concern underneath it is F1 above, which reaches the same "usage can go to zero" outcome by a different and real mechanism.


What I verified about the fix itself (all still holds)

Identity convergence. matchKey (transcripts.js:494) is the same call that builds the transcript index key (transcripts.js:690) and that settlement looks up (settle.js:148). Gap-message content is always [block] (bodies.js gapMessage), and Claude Code writes one transcript line per block - a premise already encoded here by findTranscriptMatch's "an API message split across several lines" note and by byMessageId being a list. Both sides canonicalize the same single-element array under the same role, and part_index is 0 on both, giving <uuid>#0 on both. Coverage is type-agnostic across all six GAP_BLOCK_TYPES (bodies.js:34); the PR names three as exemplars.

The rows really reach settlement. Gap messages carry no message_id, so resolveIdentity (message_projector.js:1053) returns fromFallback: true and expansion stamps gateway.identity_source = 'gateway_fallback' (message_projector.js:827) - exactly what isFallbackRow (dataset.js:588) selects - under client_name = 'claude', the key the enricher is registered with (index.js:146).

A check the PR's own tests could not make. The two lanes write different conversation_source values, and that is a cache source-partition column. scanExistingPartIds discovers partitions dataset-wide (discoverCachePartitions({ datasets: [DATASET_NAME] }), dataset.js:846), so neither lane is hidden from the other. Had it been partition-scoped the fix would have been inert in production with both new tests green.

Convergence in both write orders. OTEL-first is caught by the backfill materializer's pre-write dedupe (createBackfillDedupe, dataset.js:676); sweep-first by dedupeByPartId at flush (dataset.js:501).

foldClaudeAttributes merges rather than clobbers. { ...attributes, claude: { ...claude, ...fields } } (projection.js:297) preserves a top-level usage (anthropic.js:338 returns { usage: {...} }) and an existing claude.query_source, and the ordering is right: the body assigns message.attributes = usage before projection.js:138 folds. One correction to the PR body: the old stampQuerySource already merged this way, so it was never clobbering anything - the rename is a generalization, not the repair of a live clobber.

Regression test is load-bearing - verified both ways, not taken from the PR body. With projection.js and bodies.js reverted to origin/master and the test kept, both cases fail (tool call lands 1b6f9cb7fc436b47#0 against the sweep's 5233b3fa-...#0); on head both pass. Adjacent identity suites (claude-otel-proxy-overlap, claude-settlement, claude-projector-identity: 31 tests) green.

LLP discipline. node scripts/llp-numbers.js check - no collision on 0389. LLP 0254 is Accepted and its only edit is an appended **Extended-by:** forward ref, the sanctioned mechanical edit, matching the convention on 0027/0016/0017/0044; nothing it decided was rewritten. All @ref anchors resolve (0389#match-key-on-bodies and #scope-of-0254 explicit; 0262#migration and 0027#decision are heading slugs already used by 8+ annotations here). The narrowed 0254#identity-at-ingest gloss at projection.js:73 was updated rather than left stale.

Explicit CPU and memory pass

  • New per-record work is one matchKey per body-derived gap block: stripVolatileBlockFields, one canonicalJson string, one sha256 - the identical triple computeMessageId (message_projector.js:1228) already runs, so each gap block is now hashed twice. Same 2x the proxy fallback path has carried since LLP 0027.
  • The accumulating shape: requestGapMessages walks the whole body.messages history every turn, so per-session hashing is O(turns^2) in history bytes, over tool_result payloads that can be tens of KB, and it runs before the state.seenMessages dedupe that discards the repeats, so nothing short-circuits it. Pre-existing (the fallback id already paid it); this doubles the constant, and the formulas differ so the two hashes cannot share. LLP 0389's Consequences acknowledges the double hash but describes it as "on a path that only runs for blocks a body carries", which understates the request-body history replay - worth a sentence if that doc is ever extended.
  • Allocation: two small objects per gap message, nothing retained.
  • Settlement adds no I/O: these rows were already gateway_fallback, so hasFallback was already true, the enricher already loaded and indexed the transcript per session per flush, and dedupeByPartId already ran. New work is one Map.get per row plus one upgradeRow shallow clone on a hit.
  • Storage: an unsettled row carries a 64-char hex match_key, stripped by cleanAttributes on upgrade. Net storage strongly negative - one duplicate row per tool call removed.
  • No busy loops, no unbounded caches, nothing that worsens with uptime.

Conclusion: no CPU or memory concern.

Judgement on the two deliberate omissions

(a) No repair of already-written rows - correct, and I would have rejected a repair. Pre-fix rows carry neither a uuid nor a match-key. Re-deriving the key from content_text / tool_* is lossy, because per-part expansion discarded the block array those columns came from, and would risk collapsing genuinely distinct rows - worse than leaving a known-inflated window alone. The documented query-time rule is the honest disposition. For the release notes: say the cache does not self-heal, so dashboards over the pre-fix window stay inflated until an operator applies it.

(b) Subagent body-derived rows still not settling - correct to leave, and it fails safe. attributeMessageToEvent (projection.js:271) sets agent_id from agent.name while transcripts scope by agentId, so agentScopedKey misses. I checked the failure mode rather than assuming: the miss is a Map.get returning undefined, and because agent names and hex ids cannot collide, a subagent row cannot false-match a main-loop entry. One hash probe per row. Recording it in 0389's Consequences is right; it deserves its own issue, and does not block this.

Nits (recorded, not fixed)

  1. projection.js:138 stamps the key unconditionally where the sibling proxy producer guards with if (projected.message_id) continue (projector.js:320). Equivalent today - gapMessage never sets message_id - but a future gap message with native identity would carry dead weight settlement never cleans. A no-op guard is churn, so left alone.
  2. LLP 0389 and projection.js:130 name three block types; GAP_BLOCK_TYPES covers six. Exemplars, not an error.

Tests and typecheck, against a baseline I ran myself

Baseline was an origin/master (66c1f9a4) worktree in the same environment, not an assumption.

npm test npm run typecheck
baseline 66c1f9a4 6156 tests, 3 fail (not ok 1910/1913/1915, the icebird/hyparquet pin tests) 1 error: hypaware-plugin-kernel-types.d.ts(14,58) TS2305 'squirreling' has no exported member 'ScannableDataSource'
head f83a4b7d 6158 tests, the same 3 fail, the 2 new tests pass the same single error

Both pre-existing failures reproduce identically on the baseline. No new test failure and no new type error.

What was fixed

Nothing. F1 is deliberately out of scope for this diff (reasoning above), and the other two raised findings do not hold. No commit, no push - the head reviewed is the head as submitted.

@philcunliffe

philcunliffe commented Sep 8, 2026

Copy link
Copy Markdown
Contributor Author

neutral review round 2 - findings

Reviewed f83a4b7d3521920cb54762444b4a9f3d06a0437e against origin/master (66c1f9a4) at high effort, in a clean detached worktree. Independent pass: I re-derived every conclusion from the source and from probes I ran myself rather than inheriting round 1's.

Verdict: ship it. One MEDIUM finding, confirmed by probe, deliberately not fixed here and not a merge blocker. Nothing pushed to the branch. The round-1 finding is now tracked as #1470 (round 1 asked a human to raise it and nobody had; it is concrete, consequential, and clearly outside this task, which is exactly CLAUDE.md's bar for filing autonomously).


What I verified about the fix

It works, end to end. I built my own harness (real backfill provider, real projectClaudeTelemetryEvents, real loadSpooledBodies, real settlement enricher) on a shape the PR's own test does not cover, and watched the identity converge:

--- OTEL (pre-settle) ---
tool_call  b114091871a2d94b#0            <- gateway content hash
--- OTEL (settled) ---
tool_call  5233b3fa-...-6c0e8c0f1a2b#0   <- the sweep's transcript uuid

The convergence is structurally sound, not incidental.

  • matchKey (transcripts.js:494) is the same function that builds the transcript index key (transcripts.js:690) and that settlement looks up (settle.js:148), so the two sides cannot drift.
  • gapMessage (bodies.js:391) always builds content: [block], and Claude Code writes one block per transcript line - a premise the repo already asserts in three independent places: backfill.js:517-518 ("duplicates onto every block line"), transcripts.js:440-441 ("one line per tool_result"), transcripts.js:589-590 ("lines are single-block in current transcripts"). Both sides therefore canonicalize a one-element array under the same role, and part_index is 0 on both.
  • The cross-channel hazard this depends on is already designed for: VOLATILE_BLOCK_FIELDS (json_util.js:263-276) exists precisely so "the ai-gateway fallback message id and the claude plugin's transcript match key strip the exact same set, or the same block hashes to different identities depending on which channel delivered it." The request-body echo vs response-stream vs transcript divergence is the case it was written for.
  • Coverage is type-agnostic over all six GAP_BLOCK_TYPES (bodies.js:34-42); the PR names three as exemplars. text is deliberately not in that set, so a gap row can never collide with a content-event row.

No new I/O and no new settlement candidates. Gap rows carried no message_id before this PR, so they were already gateway_fallback (message_projector.js:827), already selected by isFallbackRow (dataset.js:588), and the enricher was already loading and indexing the transcript per session per flush. hasFallback was already true. The added work is one Map.get and one upgradeRow shallow clone per hit.

Both write orders converge: OTEL-first via the backfill materializer's pre-write dedupe (createBackfillDedupe, dataset.js:676), sweep-first via dedupeByPartId at flush (dataset.js:501). The committed scan is dataset-wide (discoverCachePartitions({ datasets: [DATASET_NAME] }), dataset.js:846), not partition-scoped, so the differing conversation_source (claude vs claude_code) does not hide one lane from the other - had it been scoped, this fix would have been inert in production with both new tests green.

foldClaudeAttributes is a safe generalization. { ...attributes, claude: { ...claude, ...fields } } (projection.js:297-305) preserves a top-level usage and an existing claude block, and the ordering is right: bodies.js:375 assigns message.attributes = usage before projection.js:138 folds. usageFromApiRequest (projection.js:413-438) can return both usage and claude, and both survive. On upgrade, cleanAttributes (settle.js:399-418) strips the spent match_key, and withToolUseResult (transcripts.js:579-584) merges rather than replaces.

No content leak. contentKey is sha256Hex(...) (transcripts.js:733-734), not embedded text, so the match_key an unsettled row keeps is a digest. Same exposure the proxy path has carried since LLP 0027.

Failure modes fail safe. A multi-block transcript line would simply not match (no settlement, duplicate persists as today, nothing mis-joined). Two identical blocks in a session collapse onto one index entry, but they already collapsed under the identical fallback hash, so no row is newly lost. A subagent gap row keys on the agent name while the index keys on the hex agentId, so agentScopedKey (transcripts.js:434) misses - a Map.get returning undefined, and names cannot collide with hex ids, so a subagent row can never false-match a main-loop entry. Correctly recorded in LLP 0389's Consequences.

LLP discipline holds. node scripts/llp-numbers.js check - no collision on 0389. LLP 0254 is Accepted and its only edit is an appended **Extended-by:** forward ref, the sanctioned mechanical edit. Every anchor resolves: 0389#match-key-on-bodies (line 39), 0389#scope-of-0254 (line 50), 0254#identity-at-ingest, 0262#migration, 0027#decision. The narrowed 0254#identity-at-ingest gloss at projection.js:73-74 was updated rather than left stale.

Style: no em dashes, no code semicolons, no NUL bytes, no new runtime dependency, no invented column or config key. Types are JSDoc; the new import is a plain .js specifier.


F1 (MEDIUM, not fixed, tracked as #1470) - the two lanes put usage on different rows, and this PR makes that consequential

hypaware-core/plugins-workspace/claude/src/telemetry/bodies.js:370 and hypaware-core/plugins-workspace/claude/src/backfill.js:517-535

LLP 0035#one-carrier fixes the carrier as the last row of a response; the gateway enforces it within one message at message_projector.js:1084-1086. The sweep obeys it (lastBlockIndexByMessageId, backfill.js:525-535). The OTEL lane does not: messageFromEvent (projection.js:344) gives the turn's usage to the assistant_response row, and responseGapMessages parks usage on the last gap block only when the response has no text block (bodies.js:370). So on a [text, tool_use] response - the ordinary tool-calling turn - the two lanes put usage on different rows.

Probed on the head, real modules throughout (this is my own run, not round 1's):

--- SWEEP (transcript backfill) ---
text       11111111-...#0   usage=null
text       22222222-...#0   usage=null
tool_call  5233b3fa-...#0   usage={"input_tokens":12,"output_tokens":34}

--- OTEL (settled, i.e. with this PR) ---
text       11111111-...#0   usage=null
text       22222222-...#0   usage={"input_tokens":12,"output_tokens":34}
tool_call  5233b3fa-...#0   usage=null

Before this PR the two tool_call rows had different ids, so the sweep's copy (carrying usage) always survived: a turn totalled 1x or 2x, never zero. After it, both rows collapse and each survivor's attributes come from whichever lane committed it first:

text row from tool row from usage counted
OTEL OTEL 1x (correct)
sweep sweep 1x (correct)
OTEL sweep 2x (the pre-existing over-count, surviving)
sweep OTEL 0x (new)

The same-lane cases dominate, so the PR is still a clear net improvement on the issue it fixes.

Addendum (mechanism, added after a parallel reviewer supplied it and I verified it in the source). My first draft said the mixed orders need an unusual commit sequence. There is a concrete and more ordinary path to the 2x row, and it is worth stating precisely because #1464 counts token double-counting as part of the bug:

A body-derived row only gets its transcript uuid at flush; until then it sits in the spool under its fallback part_id. The backfill dedupe deliberately folds the spool into its seen-set (dataset.js:530-535: "a spool hit means another producer already wrote this part"), unlike the flush-path dedupe, which deliberately does not (dataset.js:485-489). So if the sweep fires while an OTEL turn is still spooled:

  1. the sweep skips the OTEL text row (already uuid-keyed, so its part_id is recognized),
  2. the sweep does not skip the still-fallback OTEL tool row, and writes its own tool row carrying usage,
  3. at flush the OTEL tool row settles onto that uuid and dedupeByPartId drops it.

Survivors: OTEL text row (usage) + sweep tool row (usage) = the turn counted twice. So inside the spool window the row duplication #1464 reports is fixed but the token over-count is not. Outside it (OTEL flushed before the sweep runs) the sweep skips both rows and the total is a correct 1x, which should be the dominant case given a frequent flush against a cron sweep.

This does not change the verdict: the PR strictly improves every ordering and halves the row count, and the residual is the pre-existing carrier misalignment, not something this diff introduces. It does mean the release notes should not claim #1464's token over-count is fully closed until #1470 lands.

Why not fixed here. The divergence lives entirely in code this diff does not touch and is a pre-existing violation of an Accepted LLP. Aligning the OTEL lane means moving usage off the assistant_response row onto the response's last block, but the event and the body arrive as separate events and possibly separate export batches, so projection cannot know at assistant_response time whether a gap block follows; it also has to coordinate with the usageByRequestId claim (projection.js:344-347) and restoreUnclaimedUsage (source.js:709). That is a design change on a shipped lane with its own LLP, not a rider on a part_id identity fix. CLAUDE.md: "land the small one and defer the rest." Tracked in #1470 with the full probe.


Test-strength note (LOW, no change requested)

test/plugins/claude-otel-body-overlap.test.js uses a tool-only response (content: [TOOL_BLOCK]), which is exactly the shape where the two lanes agree on the usage carrier, so it is structurally blind to F1. That is fine for what the test is pinning, and I confirmed the test is genuinely load-bearing for that: it exercises the real provider, the real projection and the real enricher, not fixtures of the fix's own output. A [text, tool_use] case belongs with the F1 fix, and #1470 says so.


Explicit CPU and memory pass

Required by CLAUDE.md; done over the changed code and the affected paths.

  • New per-record work is one matchKey per body-derived gap block: stripVolatileBlockFields (one array map), one canonicalJson, one sha256. That is the identical triple the gateway's computeMessageId already runs on the same block, so each gap block is now canonicalized and hashed twice. Exactly the 2x the proxy fallback path has paid since LLP 0027, and the two formulas differ so they cannot share a digest.
  • The accumulating shape, and the one thing worth stating plainly: requestGapMessages (bodies.js:330-345) walks the whole body.messages history on every turn, so per-session gap-block hashing is O(turns^2) in history bytes, over tool_result payloads that can be tens of KB. It runs at projection, before the state.seenMessages dedupe (message_projector.js:800) that discards the repeats, so nothing short-circuits it. This is pre-existing - the fallback id already paid it - and this PR doubles the constant, dominated by canonicalJson rather than sha256. LLP 0389's Consequences calls the added work "one sha256 over content already canonicalized for the fallback id, on a path that only runs for blocks a body carries", which understates it on two counts: the content is canonicalized again, not reused, and the request-body history replay is the volume driver. Worth a sentence if that doc is ever extended; not worth blocking a fix for an active data-corruption bug.
  • Allocation: two small objects per gap message in foldClaudeAttributes, both short-lived, nothing retained. upgradeRow adds one shallow clone per settled row.
  • Settlement adds no I/O and no new scans: these rows were already fallback rows, so the transcript load, the index build and dedupeByPartId all already ran for this batch. dedupeByPartId's committed scan stays restricted to the batch's own part_ids (dataset.js:494-497, LLP 0204#fix), so nothing here reopens that GC-thrash path.
  • Unbounded growth: none. No new cache, map or set. usageByRequestId is untouched and still capped by trimUsageIndex. No busy loop, nothing that degrades with uptime.
  • Storage: an unsettled row carries a 64-char hex match_key, stripped by cleanAttributes on upgrade. Net storage is strongly negative - one duplicate row per tool call removed.

Conclusion: no CPU or memory concern that should block this change. The one item to keep on the record is the pre-existing O(turns^2) request-body replay hashing, whose constant this PR doubles; it is bounded per session, dominated by work the path already did, and outside this diff's scope.


Judgement on the two deliberate omissions

(a) No repair of already-written rows - correct. Pre-fix rows carry neither a uuid nor a match-key, and per-part expansion discarded the block array content_text / tool_* came from, so re-deriving the key would be lossy and could collapse genuinely distinct rows: worse than leaving a known-inflated window alone. The documented query-time rule is the honest disposition. For the release notes: say plainly that the cache does not self-heal, so dashboards over the 09-03-onward window stay inflated until an operator collapses on session_id + tool_call_id or reads a single conversation_source.

(b) Subagent body-derived rows still not settling - correct to leave. It fails safe (a missed Map.get, never a false match), it is unchanged by this diff, and LLP 0389 records it. It deserves its own issue but does not block this.


Tests and typecheck, against a baseline I ran myself

npm test npm run typecheck
head f83a4b7d 6156 tests, 3 fail (not ok 1910 / 1913 / 1915, the icebird / hyparquet dependency-pin tests) 1 error: hypaware-plugin-kernel-types.d.ts(14,58) TS2305 'squirreling' has no exported member 'ScannableDataSource'

Both are pre-existing and unrelated to this diff (dependency pinning and a kernel type export). The 2 new tests pass. No new test failure and no new type error.

What was fixed

Nothing in the diff. F1 is deliberately out of scope for this change and is now tracked as #1470 with a full reproduction. No commit, no push - the head reviewed is the head as submitted.

Two Consequences bullets in the doc this PR introduces overstate what the
change costs and what the backstop does.

LLP 0027 #re-settle-sweep's de-twin is a single-partition rewrite
(scanNativePartIds reads one table dir), justified there by "twins always
live in the same partition". That holds for the proxy path, where one lane
produces both rows. It does not hold for the pair this decision creates: the
transcript sweep's row lives under conversation_source = 'claude' and the
body-derived row under 'claude_code', and the cache partitions on
client_name / conversation_source / provider. So a row that misses the
flush-time pass keeps its duplicate rather than being repaired at compaction.
The flush-time pass is the one that collapses it, and its committed scan is
dataset-wide.

The cost bullet also said the match-key is "one sha256 over content already
canonicalized for the fallback id". The two formulas differ (the fallback id
folds in thread scope and agent), so the canonicalization is repeated, and it
rides requestGapMessages' replay of the whole request history, which makes it
quadratic in a session's turns.

Doc precision only, no behavior change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@philcunliffe

Copy link
Copy Markdown
Contributor Author

neutral review round 2 - findings

Reviewed f83a4b7d3521920cb54762444b4a9f3d06a0437e against origin/master (66c1f9a4) at high effort in a clean detached worktree. This is the second marker-signed record at this head; the record posted at 04:17 titles itself "round 2" but is the round-1 record (the 03:55 posting was demoted to neutral-review-superseded).

Verdict: ship it. I did not take round 1's conclusions on trust: every central claim below was re-derived from the source or from a probe I ran myself, and I went after the areas round 1 covered lightly (error and edge paths, ordering beyond its two cases, schema and migration effects, compatibility with rows already written, test gaps).

Two things are new at this head.

  1. A MEDIUM this change actually introduces (surfaced by the /code-review pass, then verified by me against the source): collapsing a text-less turn drops per-request cost fields that exist on no other lane. Not fixed here, not a blocker, recorded on Claude OTEL and transcript-sweep lanes disagree on which row carries a turn's usage, so a collapsed turn can total zero tokens #1470.
  2. A LOW doc error, fixed and pushed as b2317626 (doc-only): LLP 0389 claimed a compaction backstop that cannot reach the twin pair this decision creates.

Round 1's F1 stands as filed in #1470; I did not re-litigate it or the two probe-refuted findings.


NEW (MEDIUM, not fixed, recorded on #1470) - a collapsed text-less turn loses per-request cost, latency and the cache-token split

hypaware-core/plugins-workspace/claude/src/telemetry/projection.js:138, mechanism at bodies.js:369-377

usageFromApiRequest (projection.js:413-438) splits what the api_request event carries, and its own comment names the asymmetry: "usage mirrors the proxy path's shape ... per-request cost and latency are net-new and sit under claude". So claude.cost_usd, claude.duration_ms, claude.speed and the cache_read_tokens / cache_write_tokens breakdown exist only on the OTEL lane; the sweep reads message.usage off the transcript line, which carries none of them.

Exactly one row can hold that block: claimUsage (bodies.js:441-445) deletes the entry when it is claimed. On a text-less (tool-only) response there is no assistant_response event at all (bodies.js:302-305), so the claimed block is parked on the last gap block. That gap row is the sole carrier.

Before this PR that row had a content-hash part_id, never collided with the sweep's, and survived as a duplicate keeping the cost. After it, the row settles onto the transcript uuid and dedupeByPartId drops it whenever the sweep's twin committed first (hyp backfill claude over history, or a cron sweep that beat the spooled-body flush). That is exactly the ordering this PR's second new test pins (settled.filter(tool_call || tool_result) must be []).

Token counts survive: the sweep stamps message.usage on the same last block line (backfill.js:517-535), so this is not round 1's 0x case. What is lost is cost, latency, speed and the cache split for that turn, which nothing else on the machine holds.

Why not fixed here. The only in-diff way to preserve it is to withhold the match-key from a gap row carrying claimed usage, which re-opens #1464 for precisely the tool-only turns it is about: a worse trade. The real answer is that a part_id collapse has to merge attributes rather than drop the loser, which is a change to the gateway dedupe every client shares, and it is the same design question #1470 already owns (what each lane's rows carry). Landing that as a rider on an identity fix is what CLAUDE.md's "land the small one and defer the rest" rules out. Recorded on #1470 with the full mechanism rather than minted as a second issue for one code path and one fix.

It does not change the verdict: the PR halves the row count in every ordering and the residual is narrower than what it removes. It does mean the release notes should say the OTEL-only cost fields can go missing on tool-only turns the sweep won, alongside the pre-fix window that does not self-heal.


NEW (LOW, fixed in b2317626) - the LLP 0027 backstop does not reach this PR's twin pair

llp/0389-...decision.md Consequences said:

A block whose transcript line has not landed yet stays on its fallback id and is repaired by the LLP 0027 re-settle sweep, the same backstop the proxy path uses.

That backstop does not apply to the twin pair this decision creates, and the reason is written into LLP 0027 itself. LLP 0027 #re-settle-sweep point 1:

Same partition, guaranteed. Twins share content/role/conversation/date, so they share the Iceberg partition key ... and always live in the same partition. A single-partition compaction rewrite is therefore enough to collapse them: no cross-partition scan.

That invariant was written for the proxy path, where one lane produces both rows. This PR's pair is produced by two lanes, and the cache's source partitioning is ['client_name', 'conversation_source', 'provider'] (dataset.js:291), so the sweep's row lives under conversation_source=claude and the body-derived row under claude_code: sibling partition directories, separate Iceberg tables (discoverCachePartitions walks datasets/<name>/<col>=<val>/..., partition.js:849-900). The maintenance de-twin reads exactly one of them (scanNativePartIds(tableDir, ...), maintenance.js:2175, called as scanNativePartIds(oldDir, ...) at maintenance.js:1414), so it upgrades the row's identity at compaction but can never see the twin to drop it.

Consequence: a body-derived row that misses the flush-time settle (the finalize race: transcript line not yet on disk when the spool flushes) keeps its duplicate permanently. It is not repaired later, and it does not self-heal: once the sweep's row is committed, a later sweep merely declines to add a third.

Why it is LOW and not a blocker. The flush-time pass is the one that matters, and it is genuinely cross-partition (scanExistingPartIds -> discoverCachePartitions({ datasets: [DATASET_NAME] }), dataset.js:846), so the ordinary case collapses correctly. The residual is the pre-fix duplicate surviving in a narrow race, never a new duplicate and never a lost row. What was wrong was the doc, which claimed a backstop the code does not provide, and that is what I fixed: the Consequences bullet now states that the sweep upgrades identity but cannot collapse a cross-lane twin, names the LLP 0027 guarantee it rests on and why it does not hold here, and says the flush-time pass is what collapses this pair.

The same commit corrects the cost bullet, which said the match-key is "one sha256 over content already canonicalized for the fallback id". The two formulas differ (the fallback id folds in thread scope and agent), so the canonicalization is repeated, not shared, and it rides requestGapMessages' replay of the whole request history. Both are precision fixes to a doc this PR introduces; nothing settled was rewritten.

Verified positively rather than by a green suite: git show f83a4b7d:llp/0389-... still contains "same backstop the proxy path"; git show HEAD:llp/0389-... does not, and contains "single-partition rewrite". npm test before and after the commit: 6158 tests, the same 3 pre-existing hyparquet pin failures (not ok 1910 / 1913 / 1915), no delta.


Round 1's central claim, independently verified (confirmed, not corrected)

I re-derived each of these; none needed correcting.

The enricher actually reaches these rows. The load-bearing detail is that the OTEL lane's client_name is 'claude', not the 'claude_code' the issue quotes: conversation_source is claude_code (buildProjection, projection.js:219) but the source is registered with clientName: CLIENT_NAME (index.js:418), CLIENT_NAME = 'claude' (index.js:36), which is the key the settlement enricher is registered under (index.js:146) and the key upgradeFallbackRows dispatches on (dataset.js:426-432). Had those diverged the fix would be inert with both new tests green.

Gap rows were already fallback rows. gapMessage (bodies.js:388-397) sets no message_id, so resolveIdentity returns fromFallback: true (message_projector.js:1050-1054) and expansion stamps gateway.identity_source = 'gateway_fallback' (message_projector.js:825-830), which is what isFallbackRow (dataset.js:588) selects and what hasFallback (dataset.js:343) gates on. So the PR adds no new fallback rows and no new settlement I/O.

The convergence is type-agnostic, on a shape the PR's tests do not cover. The two tests use a tool-only response. I ran my own harness (real backfill provider, real flattenClaudeTelemetryEvents, real loadSpooledBodies, real projectClaudeTelemetryEvents, real enricher) on a [thinking, text, tool_use] response, i.e. hasText true, which is the branch responseGapMessages treats differently:

--- SWEEP ---
text        11111111-...#0
reasoning   22222222-...#0
text        33333333-...#0
tool_call   44444444-...#0
--- OTEL pre-settle ---
reasoning   5ebff6363c71c59a#0
tool_call   7a09d2c15bc4524a#0
--- OTEL settled ---
reasoning   22222222-...#0
tool_call   44444444-...#0

Both converge, including thinking, which nothing in the suite exercises.

Cross-partition flush dedupe. Confirmed at the source: scanExistingPartIds enumerates the dataset's partitions and reads each (dataset.js:838-878), so the differing conversation_source does not hide one lane from the other.

matchKey cannot drift from the index. matchKey (transcripts.js:494) is the same call that builds the index key (transcripts.js:411) and that settlement looks up (settle.js:148), and VOLATILE_BLOCK_FIELDS (json_util.js:264-276) exists to keep the request-echo, response-stream, and transcript channels hashing a block identically. tool_use.id and tool_result.tool_use_id are not volatile, so two genuinely distinct calls cannot share a key.

gap.role is never empty, so the missing if (!role) continue guard the sibling proxy producer has (projector.js:321-322) costs nothing here: requestGapMessages skips a message with no role (bodies.js:337) and responseGapMessages hardcodes 'assistant' (bodies.js:369).

Areas round 1 covered lightly - what I found

Ordering, a third case beyond round 1's two. The one that could have bitten: settlement changes a committed row's message_id from the fallback hash to the uuid, and state.seenMessages is seeded on listener start from committed message_ids per session (message_projector.js:264-300, collectSeenMessageIds at :614-623). So after a restart the seed no longer contains the fallback hash the next request-body history replay will re-derive. I checked whether that lets a duplicate through: it does not. The echo is re-projected, but dedupeStoredPartIds misses it, it is spooled, and the flush settles it onto the uuid where dedupeByPartId drops it against the committed row. Correct outcome, one extra spool round-trip per session per restart. Bounded, converging, not worth a change. Within a run, seenMessages still keys on the unchanged fallback id (message_projector.js:800), so nothing changes at all.

Schema and migration. No column, config key, or schema field is added: match_key rides the existing attributes JSON, is stripped on upgrade by cleanAttributes (settle.js:409-415), and survives stripToSchema. Nothing in the durable-cache trigger list is touched (no spool envelope or label, no cache schema or partition declaration, no generation/cursor format, no maintenance output change), so durable_cache_upgrade is not required for this change. Rows spooled by a new version and read by the old settle path would still settle: readMatchKey has been there since LLP 0027.

Compatibility with rows already written. Confirmed the PR body's claim: pre-fix rows carry no match-key, readMatchKey returns undefined, so the identity upgrade is skipped and they are returned unchanged (settle.js:145-150). One thing round 1 did not note, in the PR's favour: post-fix rows that do settle clear the gateway_fallback marker, which decrements the remainingFallbacks tally a rewrite records (maintenance.js:1465) and therefore reduces how often hasPendingFallbacks forces an otherwise-undue compaction rewrite (maintenance.js:642-659). The change is a small net reduction in maintenance work, not an addition.

Test gaps beyond the one round 1 noted. Round 1's point (the tests use a tool-only response, so they are blind to F1 / #1470) is correct and I confirmed it. Two more, all LOW and none worth landing here: nothing covers a thinking block (I probed it above, it works), nothing covers the unsettled-at-flush path that LLP 0389's second Consequences bullet is about (which is how the doc error above went unnoticed), and nothing covers the subagent non-match. The tests that exist are load-bearing and use the real modules end to end, including both body lanes: the tool_call comes from the response body and the tool_result from the request-body echo.

Failure and edge paths. A transcript that cannot be read leaves index undefined and skips the upgrade without disturbing the independent cwd late-resolution (settle.js:322-334) - the #258 hole stays closed. A missing body_ref or unreadable spool file yields no gap messages at all (projection.js:113-115). A subagent gap row keys on agent.name while the index keys on the hex agentId, so agentScopedKey (transcripts.js:434) misses: a Map.get returning undefined, never a false match, correctly recorded in 0389.

Explicit CPU and memory pass

Required by CLAUDE.md. Done over the changed code and the affected paths.

  • Round 1's O(turns^2) claim: confirmed, not corrected. requestGapMessages (bodies.js:330-344) walks the whole spooled.body.messages history on every request body, and the new matchKey call (projection.js:138) runs on every gap block it yields, before the state.seenMessages dedupe (message_projector.js:800) discards the repeats. So per-session gap-block hashing is quadratic in turns, over tool_result payloads that can be tens of KB, and this PR doubles the constant. I checked whether the two hashes could share work: they cannot. computeMessageId folds in threadScope and agentId (message_projector.js:1052) while matchKey is role plus canonical content only, so both the canonicalJson and the sha256 are repeated. This is the same 2x the proxy fallback path has paid since LLP 0027, and it is now stated honestly in the doc (previously it said "one sha256 over content already canonicalized for the fallback id", which understated it on both counts).
  • Allocation: two short-lived objects per gap message in foldClaudeAttributes (projection.js:298-306), nothing retained; one shallow clone per settled row in upgradeRow.
  • New I/O: none. These rows were already fallback rows, so the transcript load, the index build and dedupeByPartId all already ran for the batch. The added work is one Map.get per row plus one clone per hit.
  • Unbounded growth: none. No new map, set, or cache. dedupeByPartId's committed scan stays restricted to the batch's own keys (dataset.js:501-509, LLP 0204#fix), so the GC-thrash path is not reopened. usageByRequestId is untouched.
  • Behavior with uptime and volume: one extra spool round-trip per session per listener restart (above), bounded and converging. Against that, settled rows clear the fallback marker and reduce forced compaction rewrites, and one duplicate row per tool call disappears from storage.
  • Storage: an unsettled row carries a 64-char hex match_key (a digest, contentKey is sha256Hex, transcripts.js:733, so no content leak), stripped on upgrade. Net strongly negative.

Conclusion: no CPU or memory concern that should block this change. The one item on the record is the pre-existing quadratic request-body replay hashing, whose constant this PR doubles; it is bounded per session and dominated by work the path already did.

Release note the PR does not carry

CLAUDE.md is categorical: a release that touches the claude adapter (@hypaware/claude, the telemetry listener, the body spool, or the attach settings writer) must run claude_otel_shape_check, and "it is not optional for those releases". This PR changes telemetry/projection.js, so the release carrying it inherits that gate. Neither the PR body nor LLP 0389 mentions it. Also worth restating in the notes, from round 1: the cache does not self-heal, so dashboards over the pre-fix window stay inflated until an operator collapses on session_id + tool_call_id or reads a single conversation_source.

node scripts/llp-numbers.js check: no collision on 0389. All @ref anchors resolve (0389#match-key-on-bodies line 39, 0389#scope-of-0254 line 50, 0254#identity-at-ingest, 0252#bodies-for-gaps, and 0027#decision / 0262#migration as heading slugs). LLP 0254's only edit is the appended Extended-by: forward ref, the sanctioned mechanical edit. No em dashes, no code semicolons, no NUL bytes, no new dependency, no invented column or config key.

The two other /code-review findings, and where they land

The high-effort /code-review pass returned three. The MEDIUM above is its, and it is real. The other two were already on the record and I confirmed both rather than restating them as new:

  • Sidechain rows never settle (LOW). Correct: attributeMessageToEvent sets agent_id from the OTEL agent.name while the transcript index keys on the hex agentId, so agentScopedKey (transcripts.js:434) can never hit. Round 1 recorded it and LLP 0389 carries it as a stated consequence. It fails safe (a Map.get returning undefined, never a false match), so it is an accepted scope limit, not a defect of this diff. Worth saying plainly in the release notes though: "one tool call is one row" holds for the main loop, not for Task subagent traffic.
  • The canonicalization is recomputed, not reused (LOW, CPU). Correct, and the item round 1 flagged and I confirmed independently below. Its suggestion to hoist or thread the canonical form through would have to cross the plugin/gateway boundary (the plugin computes matchKey, the gateway computes the fallback id), so it is not a small change; b2317626 states the true cost in LLP 0389 instead.

Its non-finding note asks whether 0389's number came from the minting script. node scripts/llp-numbers.js check against refs/remotes/origin/master: no collision on 0389.

Verdict

Ship it. The fix is correct, and I verified that independently rather than inheriting it: an end-to-end probe on a [thinking, text, tool_use] shape the tests do not cover, plus the two structural facts the whole fix rests on (client_name is claude on both lanes, and the flush dedupe's committed scan is dataset-wide). Round 1's disposition on #1470 was right and I left it alone.

Two additions at this head: a MEDIUM this diff genuinely introduces (a collapsed text-less turn loses the OTEL-only cost fields), not fixed here for the reasons above and recorded on #1470; and a LOW doc error, fixed and pushed as b2317626, doc-only, verified in the committed tree rather than by a green suite. npm test before and after the commit: 6158 tests, the same 3 pre-existing hyparquet pin failures, no delta.

@philcunliffe

Copy link
Copy Markdown
Contributor Author

neutral triage - deferred findings fanned out

Triaged head b23176267fd0dace3310cbcb11f0968cc878c1f8 after the review-round cap (2 rounds) was reached with findings open. Every residual finding is non-blocking, so the PR can merge safely; each one now has its own tracking issue.

Resolved at this head (no issue): the round-2 LOW (LLP 0389 overstated the LLP 0027 re-settle backstop) was fixed by the head commit b2317626 itself, doc-only.

# Finding (last review round's order) Severity Judgement Issue
1 Collapsing a text-less (tool-only) turn drops the OTEL-only claude.cost_usd / duration_ms / speed and the cache-token split when the sweep's row committed first (bodies.js:360-377, projection.js:413-438) MEDIUM Non-blocking: narrow ordering residual of the pre-existing lane divergence; the only in-diff mitigation re-opens #1464; the fix is an attribute-merging dedupe, a shared-gateway design change #1472
2 The two lanes put a turn's usage on different rows (projection.js:344 vs backfill.js:517-535), so a collapsed [text, tool_use] turn can count 0x in a mixed commit order, and the spool-window ordering still counts 2x MEDIUM Non-blocking: pre-existing LLP 0035 carrier violation in code this diff does not touch; dominant same-lane orderings are correct and the PR strictly improves every ordering #1470

Both issues carry the neutral:fix label and the deferred-finding identity marker for this head.

@philcunliffe philcunliffe added the neutral:approved neutral reviewed this and holds it for a maintainer merge (own or adopted PR; LLP 0025/0030) label Sep 8, 2026
@philcunliffe
philcunliffe added this pull request to the merge queue Sep 8, 2026
@philcunliffe

Copy link
Copy Markdown
Contributor Author

Ship risk: medium

Who could be affected: People whose Claude Code sessions are recorded by HypAware, and anyone reading token or cost reports built from that history.

What could happen:

  • A turn's token and cost figures could be recorded as zero, so a usage report quietly undercounts. It needs a specific timing overlap between the two ways a session is recorded, but that overlap is ordinary on a machine in daily use.
  • Sessions recorded before this change keep the duplicates they already have; nothing is repaired retroactively, so reports over that earlier window stay inflated until they are adjusted by hand.

Why this level: The effect is limited to the accuracy of numbers in locally stored session history, which is the very thing this change sets out to correct: today the same tool call is stored twice, inflating recent reports by about a third. Nothing here touches sign-in, permissions, privacy, or the content of recorded conversations, and nothing leaves the machine that did not before.

What was checked: Two independent runs proved no recorded message is lost or attached to the wrong turn: separate tool calls stay separate, and when the matching session file is missing or holds different content, the record is left exactly as it is today. 926 automated checks covering this area passed, including a new one for the duplicate this change removes. The zero-token case was reproduced deliberately and is already tracked as a separate follow-up.

Merged via the queue into master with commit 213098e Sep 8, 2026
8 checks passed
@philcunliffe
philcunliffe deleted the fix/issue-1464 branch September 8, 2026 16:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

neutral:approved neutral reviewed this and holds it for a maintainer merge (own or adopted PR; LLP 0025/0030)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Claude Code sessions recorded twice since OTEL attach: transcript backfill and OTEL lanes never dedupe

1 participant