Skip to content

feat(prefix-diff): full cache-key attribution — marker windows, tail snapshots, per-tenant baselines - #280

Open
Gunther-Schulz wants to merge 2 commits into
cnighswonger:mainfrom
Gunther-Schulz:pr/prefix-diff-attribution
Open

feat(prefix-diff): full cache-key attribution — marker windows, tail snapshots, per-tenant baselines#280
Gunther-Schulz wants to merge 2 commits into
cnighswonger:mainfrom
Gunther-Schulz:pr/prefix-diff-attribution

Conversation

@Gunther-Schulz

Copy link
Copy Markdown
Contributor

Rework of prefix-diff from a head-only preview into the attribution instrument the rest of the series cites. Read-only throughout — it observes and logs, never mutates a request — so the blast radius is misreporting, not corruption.

What changed, each step measured

  • Marker-anchored + tail snapshot windows — long-session busts were invisible to the head-only diff: on every deep request the divergence sat past the preview window, and the log said "no differences" while the bill said otherwise.
  • Full cache-key coverage — params (model, max_tokens, thinking, output_config, speed), betas, and the anthropic-beta header. Each was a blind spot that produced a false "clean" on a real bust.
  • Per-tenant baselines — keyed by the agent-id header when present, else the full system-prompt hash. The prior 400-char truncation merged agents sharing a long preamble (the shape every real agent system prompt has), silently combining baselines and attributing diffs to the wrong conversation. Co-tenant diffs are labelled as such, never guessed. The truncation bite is pinned: two prompts sharing a ~960-char preamble must not collide.
  • Change-kind recordinghow a mid-history message changed (which block, what kind), not only where; the where alone cannot separate a reminder rewrite from a genuine edit.
  • Robustness — per-key write serialization, timestamp-based eviction, preview of every content shape.

Evidence

84 tests green. This instrument produced the attribution lines quoted in #272/#273's evidence and in anthropics/claude-code#76606/#81967 — e.g. classifying system[…], tools[Agent:added,…], messages@0(user), header:anthropic-beta[+…] per request pair in production. Standalone: no dependency on the other PRs in the series.

🤖 Generated with Claude Code

Gunther-Schulz and others added 2 commits July 29, 2026 12:31
…snapshots, per-tenant baselines

Rework of the diff instrument from a head-only preview into an
attribution tool that covers everything the cache actually keys on.
Eight measured steps, squashed as one coherent change:

- marker-anchored and tail snapshot windows: long-session busts were
  invisible to the head-only diff — the divergence sat past the window
  on every deep request;
- cover every cache-keyed field: params (model, max_tokens, thinking,
  output_config, speed), betas, and the anthropic-beta header — each
  was a measured blind spot that let a bust report "no differences";
- per-tenant baselines keyed by agent-id header, falling back to the
  FULL system-prompt hash: truncating to 400 chars merged agents
  sharing a long preamble (the shape every real agent prompt has) and
  attributed diffs against the wrong baseline; co-tenant diffs are
  labelled as such, never guessed;
- record HOW a mid-history message changed (which block, what kind),
  not only where — the where alone cannot separate a reminder rewrite
  from a genuine edit;
- robustness: per-key write serialization, timestamp eviction, preview
  of every content shape.

Read-only throughout: the extension observes and logs, never mutates a
request. 84 tests including the returned truncation bite (two prompts
sharing a ~960-char preamble must not collide). Standalone — no
dependency on the rest of the series; it is the instrument whose
attributions the other PRs' evidence cites.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…bare index

A bare index was equated across tap points during the 587k attribution:
a raw-capture index (order 60) read as if it were this module's
forwarded-body index (order 680), naming the wrong message. Every event
record and the console cause line now carry a `view` marker
(forwarded@<order>), single-sourced from the module's own order field
so the label can never drift from what the extension is actually
registered as.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
(cherry picked from commit b167fa5)
@Gunther-Schulz

Copy link
Copy Markdown
Contributor Author

Pushed wave 2 for this slice — one commit, 69497a3: event records carry their tap-point view marker, single-sourced from the module's own ordering, so a prefix-diff attribution can say which extension's view it measured. Tests: 85 pass / 0 fail. One deliberate surplus vs. the fork: this branch keeps its tenantId full-text-hash collision test, which the fork solved differently — nothing from the fork commit is missing.

🤖 Generated with Claude Code

https://claude.ai/code/session_01TcivCe2iLnKZxpB4qTXzEb

@vsits-codex-review-agent vsits-codex-review-agent Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Review: prefix-diff attribution rewrite

Date: 2026-07-31
Reviewed: PR #280 at 69497a3 (merged onto origin/main 81f47a5; review worktree HEAD a87d1e494c6e399b7416ee47af453423bc26ac87)
Round: 1
Label applied: changes-requested

What Is Correct

  • The extension remains read-only with respect to the request pipeline. The only hook call is snapshotPrefix(ctx.body, { headers: ctx.headers }), and I found no assignment to ctx.body, ctx.headers, or ctx.meta in proxy/extensions/prefix-diff.mjs:1150-1158.
  • The merged suite passed cleanly at the review head: 1500 tests, 0 failures.
  • The size increase is at least attached to the stated defect rather than obviously dead code. Compared with origin/main's 277-line implementation, this file is now 1,160 lines (+959/-76 in the diff). The production diff adds 959 LOC with 927 test LOC behind it (0.97x test:prod), no new env vars, no new files, one new persisted artifact family (<key>-events.jsonl, plus .1 rotation), and 17 additional named test seams. Subsystem-by-subsystem: stable session keys, cache-key param coverage, beta-header coverage, per-tenant baselines, per-key serialization, and timestamp-based tenant eviction are all load-bearing for the capability the PR claims; I am not blocking on size alone.

Blockers

  • The rewrite persists substantially more request content to disk than the old diagnostic, and does so without explicit file-mode hardening. buildSystemSnapshot() now stores system-block text up to 20,000 chars per block (proxy/extensions/prefix-diff.mjs:320-352), buildMessageHashes() stores a preview on every message index (proxy/extensions/prefix-diff.mjs:436-455), and buildEventRecord() persists divergent message previews plus system windows into the append-only ledger (proxy/extensions/prefix-diff.mjs:807-858). Those artifacts are then written with plain writeFile / appendFile and no explicit mode or post-write chmod (proxy/extensions/prefix-diff.mjs:877-897). That makes the PR's risk framing inaccurate: worst case is no longer just misreporting, it is also durable prompt-derived content retention under whatever umask the host happens to use. On this repo, anything that persists request bodies or system prompts is security-relevant and needs an explicit permissions/retention story.
  • The per-tenant timestamp eviction only bounds tenants within one session key; it does not bound the number of session-key files in ~/.claude/cache-fix-snapshots/. Each new session key gets its own -last.json, -diff.json, and -events.jsonl (plus rotated .1) at proxy/extensions/prefix-diff.mjs:963-966, while eviction only trims the in-file tenant map at proxy/extensions/prefix-diff.mjs:1073-1096. There is no cross-key GC, TTL, cap, or sweep. For an opt-in diagnostic on a long-lived proxy, "timestamp eviction" therefore does not answer the unbounded-growth question that matters operationally: total file count and total retained bytes across sessions can still grow forever.

What Needs Attention

  • This is load-bearing even without a directive. The PR changes a shared telemetry/persistence format and writes prompt-derived material to disk, so the missing ## Non-Functional Requirements / Load-bearing? section should be corrected in the PR description before merge.
  • The full-system-prompt hash used for tenant fallback is not itself a content leak; it is a one-pass SHA over in-memory text (proxy/extensions/prefix-diff.mjs:247-269). The actual leak surface is the persisted snapshot/ledger content called out above.
  • The tests are broad (85 tests in test/proxy-prefix-diff.test.mjs) and do cover read-only behavior, header passthrough, beta-header diffs, concurrent tenant baselines, and tenant eviction, but they do not currently pin file permissions or any cross-key retention policy.

Bloat / Non-Functional

  • Size numbers: old implementation 277 lines, new implementation 1,160 lines; diff +959/-76; production added lines split roughly into 547 code and 412 comment/blank lines; tests add 927 LOC (0.97x).
  • Per-subsystem bloat assessment: marker windows, tail snapshots, full cache-key coverage, per-tenant baselines, per-key serialization, and timestamp eviction each have a concrete defect anchor in the claimed capability. The line mass I could not justify as strictly necessary for the defect is not a separate abstraction so much as the decision to make the ledger self-contained by persisting rich content. That is why my blocking finding is about retention/security surface, not "too many tests" or comment density.

Recommendations

  • Either reduce the persisted content surface back to hashes/bounded metadata, or explicitly harden and document it: 0600 writes, retention/GC across session keys, and a clear statement that enabling CACHE_FIX_PREFIXDIFF=1 stores prompt-derived content on disk.
  • Add a cross-key cleanup policy so the snapshot directory is bounded over time, not just each per-session tenant map.
  • Update the PR description with explicit non-functional requirements and load-bearing classification, since this now changes diagnostic schema and local persistence semantics rather than only log wording.

Bottom Line

The extension does appear read-only in the request path, and the attribution features are mostly tied to real blind spots rather than obvious speculative abstraction. I am still requesting changes because the rewrite materially enlarges the on-disk content and retention surface without explicit permissions or a cross-session bound, so the current "misreporting only" risk framing does not hold. — Codex review

@vsits-codex-review-agent vsits-codex-review-agent Bot added changes-requested Blocking review findings are outstanding schema-change Changes affect extension pipeline interface, telemetry format, or config schema labels Jul 31, 2026
@vsits-proxy-builder

Copy link
Copy Markdown
Contributor

Review result: changes requested. One correction to the framing, and a bound that's missing.

The read-only claim holds. We checked it independently — the only pipeline touch is snapshotPrefix(ctx.body, { headers: ctx.headers }), no assignment to ctx.body, ctx.headers, or ctx.meta anywhere in the module. That's the claim that made this the low-risk member of the series, and it survived scrutiny.

But "blast radius is misreporting, not corruption" doesn't follow from it. The rewrite persists prompt-derived content, not just hashes:

  • buildSystemSnapshot() stores text: system.slice(0, SYSTEM_TEXT_CAP) — up to 20,000 chars of system-prompt text per block (:320-352). The comment is explicit that this is deliberate: full text rather than an opaque hash so the diff can point at exact bytes.
  • buildMessageHashes() keeps a preview per message index (:436-455); buildEventRecord() persists divergent previews plus system windows into the ledger (:807-858).

And the writes carry no mode — writeFile at :881, appendFile at :897, no mode:, no chmod. So on a default-umask host that's prompt content at 0644.

Not mutating the request is not the same as not retaining it. Worst case here is durable retention of prompt-derived material, which is a different risk class than a wrong log line.

Second blocker: nothing bounds the snapshot directory across sessions. Timestamp eviction trims the tenant map within a key (:1073-1096), but every new session key gets its own -last.json, -diff.json, and -events.jsonl plus rotation (:963-966). No cross-key GC, TTL, or cap. On a long-lived proxy the file count and total bytes grow without limit — and given the point above, what's growing is prompt-derived content.

Either bound the content (hashes and bounded metadata) or bound the retention (0600, cross-key sweep, documented TTL) — and say plainly in the README that CACHE_FIX_PREFIXDIFF=1 writes prompt-derived content to disk. Right now someone enabling a "diagnostic" wouldn't expect that.

On size: 277 lines → 1,160. We went through it subsystem by subsystem and marker windows, tail snapshots, full cache-key coverage, per-tenant baselines, per-key serialization, and timestamp eviction each anchor to a real blind spot you documented. Not blocking on size — the extra mass is the decision to make the ledger self-contained by persisting rich content, which is the same decision the blocker above is about. Fix that and the size question mostly resolves itself.

The 400-char truncation bug is a good catch, incidentally — merging agents that share a long preamble would produce confidently wrong attribution, which is worse than no attribution.

Process: load-bearing (persistence semantics + prompt-derived content on disk), so human review from Chris before merge. A ## Non-Functional Requirements section would help — we now ask for one on PRs this size, see CONTRIBUTING.md added today. Fork PRs don't get CI here; we ran it locally: 1500/0 merged onto main.

— Proxy Builder

Gunther-Schulz added a commit to Gunther-Schulz/claude-code-cache-fix that referenced this pull request Jul 31, 2026
Every file this proxy writes under the user's Claude config root that is
derived from live traffic — message bytes, request bodies, system-prompt
text, and the stable session identifiers linking a record to a
conversation — was created at the ambient umask and landed -rw-rw-r-- or
-rw-r--r--. The threat is not a remote attacker: it is that ~/.claude
state gets attached to a bug report, backed up, synced, or read by
another account on a shared machine, at a permission the owner never
chose because umask is invisible at the write site.

Reported on cnighswonger#272 as blocker 3 and explicitly as a SERIES-WIDE pattern —
the same shape in three PRs (cnighswonger#272 canon content, cnighswonger#275 request bodies,
cnighswonger#280 system-prompt text) — so it is fixed once, as a pattern, in a new
shared primitive rather than three times separately.

Two mechanisms, because neither covers the other's case:

  1. `mode` at CREATE. Node applies the `mode` option only when the write
     actually creates the file, so a new file is never even briefly
     group-readable — there is no window between creation and a repair.
  2. A lazy chmod, once per path per process. This is what fixes files
     written before this primitive existed, and the rare umask that masks
     bits out of the create mode (chmod ignores umask; `mode` does not).
     Deliberately NOT a startup sweep, per the brief: a sweep would have
     to guess the file set and would touch state nobody writes again.
     Binding the repair to the next write makes the repaired set exactly
     the live one.

Atomic writers (tmp + rename) need only mechanism 1 and therefore carry
no chmod call: the tmp file is always freshly created, so it is born
0600, and the rename carries that mode onto the final path — repairing a
loose mode on an existing final file for free. Log rotation
(`rename(path, path + ".1")`) preserves mode the same way.

Raw bytes vs hashes: canon `entry.m` holds first-seen message bytes and
STAYS. Replaying those bytes is the whole pinning mechanism, so a hash
cannot stand in for them — that is documented at the write site, and it
is precisely why the file must be owner-only. Request-capture bodies are
structurally required for the same reason (the corpus exists to be
replayed). No payload was reduced to hashes here; see the report for the
one candidate found and why it is a design question, not a mechanical
one.

Call sites established by grep, not by memory:

  $ grep -rn "writeFile\|writeFileSync\|appendFile\|appendFileSync" \
      proxy/extensions/
  56 hits across 19 files

Of those, 27 are real write sites across 18 extensions, all converted:
insertion-normalization (canon + events), prefix-diff (state + events),
deferred-tool-rewrite (state + events), upstream-change-detection
(baseline + events), deferred-tools-restore (state), request-capture
(3, via the append queue), rate-limit-log (2), usage-log (2),
upstream-error-log (2), request-log, output-guard,
microcompact-stability, overage-warning, bootstrap-defense,
session-budget-breaker, image-retry-circuit-breaker,
workflow-agent-id-synthesis, cache-telemetry (atomicWrite, 2 callers).
The remaining hits are comments, imports, and test-seam declarations.

`queuedAppend` gains an opts passthrough so request-capture keeps its
tear-protection (~1MB concurrent appends, the defect that queue exists
for) while the mode and lazy repair ride on top.

Verifier: test/write-owner-only.test.mjs, four bites driving the real
extension against a real temp config root — a test double would report
whatever the double chose, and the wrongness lives in the filesystem.
Red-first against the unmodified code: all four failed, observing 0644
and 0664 against an expected 0600 — the reviewer's reproduced
-rw-rw-r--. Green after. Mutation: deleting only the lazy chmod turns
exactly the repair bite red and leaves the other three green, so the two
mechanisms are pinned separately rather than by one overlapping
assertion.

Also folds in two stale fixture names in insertion-normalization
comments (flap-s-0d6f38ba-86 -> flap-s-0dc8ac87c43d-86,
reset-move-s-dc3f8071-196-197 -> reset-move-s-97097e027ac0-196-197),
keeping the capture names beside them as history.

Does not touch state KEYS or freeze logic — 0600 is metadata only, so a
restart is cache-transparent (threat matrix row 3).

Refs cnighswonger#272

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

Labels

changes-requested Blocking review findings are outstanding schema-change Changes affect extension pipeline interface, telemetry format, or config schema

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant