Skip to content

Commit 0633e23

Browse files
refresh: the tools as they actually are — checker fix, successions, shape watch, hardened sweep
The branch was cut before an afternoon in which the daily sweep and its own findings changed these tools materially; as opened, the PR shipped a safety checker with a known bug (the declared-injection exemption filtered the output side only — an input carrying an injection-shaped message, e.g. a chained proxy feeding the pipeline its own output, read as a message drop nobody made; caught by the first stamped sweep and fixed with a both-ways bite). What this refresh brings, each with its tests: - safetyViolation filters declared injections on BOTH sides; - findSuccessions: conversation-boundary classification (compaction / resume-shaped / fork) with opener pricing; interleaves structurally suppressed (never-returns + first-appearance conditions, both bite-proven — the one-shot-sidecar phantom was caught by its own test); - census edit rows carry lastHumanAt/anchorDelta (the relation that attributed the mid-history edit population to reminder anchoring), with far-from-anchor rows flagged and their bytes excerpted to local stdout only; - harvest: shape watch counters (dormant thinking classes, baseline prefix sizes) and growth-step snapshots (evidence frozen, scrubbed, before capture rotation eats it); shape-verdicts.mjs is the consumer so the new ledger fields are never orphan telemetry; - gate-live: --census on every sweep, rows that compared zero pairs marked proves-nothing (ok requires a proving row), and the status file stamps the proxy/tools source fingerprints it exercised — a verdict that names its config but not its code stays "fresh" across code changes it never saw. proxy/source-fingerprint.mjs is included for the stamp; identical file ships in the capture PR — merges cleanly in either order, same precedent as the shared fixtures. docs: dev-loop's closing-gate section and the consumer setup page ride along. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 57e591b commit 0633e23

12 files changed

Lines changed: 1198 additions & 17 deletions

docs/CONSUMER-SETUP.md

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
# Consumer setup — the protection set, nothing else
2+
3+
For running this fork purely as protection: no captures, no telemetry, no
4+
development machinery. Install and launch mechanics are upstream's — follow
5+
the main [README](../README.md) ("Quick Start", "Running as a service");
6+
this page only tells you **which switches to turn on and why**.
7+
8+
## What it protects against
9+
10+
Claude Code re-sends the whole conversation every request; Anthropic bills
11+
the unchanged part cheaply only while the bytes match exactly. Three CC
12+
behaviors break that match and silently re-bill six-figure token counts:
13+
14+
1. **Old reminder blocks get re-shaped mid-history**
15+
([anthropics/claude-code#76606](https://github.com/anthropics/claude-code/issues/76606),
16+
[#78660](https://github.com/anthropics/claude-code/issues/78660)) — an
17+
edit deep in the history re-bills everything after it.
18+
2. **The tools list changes when a tool loads mid-session**
19+
([#81967](https://github.com/anthropics/claude-code/issues/81967)) — the
20+
tools list heads the cached prefix, so one late tool load re-bills the
21+
entire context.
22+
3. **Byte drift in already-sent messages** (stray whitespace, block
23+
re-serialization —
24+
[#48734](https://github.com/anthropics/claude-code/issues/48734)) — any
25+
2-byte wobble invalidates the whole prefix.
26+
27+
The extensions below hold the forwarded bytes stable across all three, and
28+
a last-line guard makes sure no mitigation can ever corrupt a conversation:
29+
on any structural mismatch it forwards the original untouched.
30+
31+
## The switches
32+
33+
Bake these into the service at install time (see the README's
34+
`install-service` section — flags set at install time land in the unit):
35+
36+
```sh
37+
CACHE_FIX_FORWARD_PROXY=on \
38+
CACHE_FIX_INSERTION_NORMALIZE=1 \
39+
CACHE_FIX_VOLATILE_PIN=1 \
40+
CACHE_FIX_TOOL_REWRITE=1 \
41+
CACHE_FIX_OUTPUT_GUARD=1 \
42+
cache-fix-proxy install-service
43+
```
44+
45+
| switch | what it does |
46+
|---|---|
47+
| `FORWARD_PROXY=on` | transport mode — Claude Code connects through the proxy with no `ANTHROPIC_BASE_URL` change |
48+
| `INSERTION_NORMALIZE=1` | recognizes messages by content, so relocated/re-shaped history is forwarded in its first-seen form |
49+
| `VOLATILE_PIN=1` | pins reminder blocks to their first serialization — CC's re-stamps stop reaching the wire |
50+
| `TOOL_REWRITE=1` | freezes the tools list; late-loaded tools are announced at the tail instead of re-writing the prefix (auto-limited to models measured to support it — everywhere else it degrades to stock behavior, never an error) |
51+
| `OUTPUT_GUARD=1` | the safety net: validates structure after all mitigations and restores the original on any violation |
52+
53+
Everything upstream ships enabled by default stays enabled — those handle
54+
further stabilization (fingerprint stripping, sort stabilization, etc.).
55+
56+
**Deliberately NOT enabled** (development/telemetry, not protection):
57+
`REQUEST_CAPTURE`, `SESSION_MIRROR`, `PREFIXDIFF`, `UPSTREAM_DETECTION`
58+
these record traffic for the verification machinery. A consumer needs none
59+
of them; leaving them off means nothing about your conversations is written
60+
to disk beyond what Claude Code itself stores.
61+
62+
## How you'd notice it working
63+
64+
The mitigation is invisible by design — the observable is your usage:
65+
long sessions stop hitting sudden six-figure `cache_creation` spikes on
66+
turns where nothing big changed. If you suspect a problem, the guard's
67+
restore events land in
68+
`~/.claude/cache-fix-snapshots/guard-events.jsonl`; an empty or absent
69+
file is the normal state.

docs/dev-loop.md

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,31 @@ Three things worth keeping from the episode:
166166
input dies against the cap and fails the sweep the same day, whatever the
167167
fourth wall turns out to be made of.
168168

169+
## The census names the class; only content names the cause
170+
171+
Row 4 sat "re-opened" for a day with the mechanism unexplained — while an
172+
outside reporter with far lighter tooling (#78660) had already named it. The
173+
gap was not effort; it was a structural blindness we designed in: the census
174+
reduces messages to hashes and ordinals, which is what makes it scalable and
175+
publishable, and exactly what makes it causally mute. Hashes can say
176+
same/different/moved; they cannot say "this is the task-tools nudge, and it
177+
anchors to the last human message." Two rules from the miss:
178+
179+
- **When a class is localized, return to the bytes and to the STRUCTURE.**
180+
Read the actual content at the offending position (once, locally — the
181+
privacy discipline applies to what gets committed, not to what gets read),
182+
and relate the position to conversation structure: roles, anchors,
183+
injection zones. The verdict that closed row 4 was one 30-line matcher
184+
relating edit positions to the last human-typed message (20 of 22 within
185+
±2). That relation now lives in the census itself (`anchorDelta` on every
186+
edit row, with a "far from any anchor = new mechanism" callout) — the
187+
matcher was the prototype, per the standing rule about throwaway probes.
188+
- **Sweep the public tracker when an investigation OPENS, not after it
189+
ships.** The row-4 mechanism sat in a public issue for over two weeks
190+
while we derived the same facts independently. One `gh search issues` per
191+
new unexplained class converts an investigation into a verification —
192+
strictly cheaper, and the verification is worth posting back.
193+
169194
## Never hand-roll identity in a probe
170195

171196
Twice on 2026-07-28 a throwaway probe reached a wrong conclusion because it
@@ -228,6 +253,66 @@ Mechanised on the dotfiles side: `bootstrap/doctor.py` enumerates its own
228253
test, so a new verdict cannot be added without its could-not-verify case being
229254
exercised.
230255

256+
## The closing gate: four questions before any proxy work is done
257+
258+
MANDATE (operator, 2026-07-29). Every piece of work here — a fix, an
259+
investigation, a probe, a doc — answers these four before it closes. Each
260+
question has a same-day precedent where skipping it cost real time; "no"
261+
is an acceptable answer, silence is not — and a "no" or "not yet" must
262+
NAME the missing evidence or design element, which converts it into a
263+
spec. An unnamed deferral is drift, and a deferral justified by a cited
264+
rule that collapses under one question was a rationalization, not a
265+
reason (same day: a trend alarm was declined citing red-before-build,
266+
which synthetic bites already satisfied; naming the real concern —
267+
false-fires on deliberate changes — produced the design that dissolved
268+
it, acknowledge-by-commit, within the hour).
269+
270+
1. **Can this be mechanized?** Interpretation stays human; everything
271+
around it is machinery — the check, the annotation, the alarm, the
272+
EVIDENCE DELIVERY. The tell remains the throwaway probe: row 4's verdict
273+
came from a 30-line matcher that became `anchorDelta` the same day, and
274+
the byte-extraction friction that stalled the row for a day became the
275+
far-from-anchor excerpt pass. If the answer is "it needs judgment", ask
276+
again about the part BELOW the judgment: delivering the inputs to the
277+
judgment is always mechanizable.
278+
2. **Is the evidence harvestable?** Captures rotate on a quadratic clock;
279+
a finding that rests on volatile bytes is a finding with an expiry date.
280+
If the claim would be unverifiable after rotation, snapshot what proves
281+
it — sanitized, via the harvest path — before closing (precedent: the
282+
growth-step spec exists because a baseline step's explaining diff dies
283+
with the capture).
284+
3. **Does the census need a new class or annotation?** A class you named
285+
by hand while investigating is a classification the census should emit
286+
— otherwise the next instance gets re-derived instead of recognized
287+
(precedent: `anchorDelta`, occurrence ordinals, the tools-delta kinds
288+
all started as hand-derivations). A NAMED deferral can still answer
289+
the wrong question here: whether the class deserves an ALARM is
290+
question 4's concern — question 3 asks only whether a classification
291+
now exists by hand, and a probe that assigns kinds or counts to
292+
traffic answers it YES by existing. The one valid deferral argues the
293+
derivation is genuinely one-off. (Observed: the resume-boundary
294+
classifier was parked with an alarm-shaped basis minutes after its
295+
probe had hand-classified every capture; one operator question undid
296+
the parking.)
297+
4. **Did the instruments ride along?** A mitigation change without its
298+
replay/gate change ships blind: the gate replays the SERVING config, so
299+
an instrument that lags the extension verifies a pipeline nobody runs
300+
(precedent: the day every gate run exercised defaults while production
301+
ran eleven gates). New state, new record fields, new gates — each lands
302+
with its replay handling, its ledger declaration, and its three-answer
303+
doctor verdict in the same change.
304+
305+
### Cadence: the gate guards the flow, the sweep re-checks the stock
306+
307+
The closing gate runs at work-time, per change. A dispatched stock-sweep
308+
(read-only, the four questions over the WHOLE system) is for after building
309+
bursts — the 2026-07-29 sweep found twelve gaps because twelve pieces of
310+
machinery had just landed, and its top finding was live within the hour.
311+
Not a standing schedule: standing machinery must be maintained forever, and
312+
a sweep of an unchanged system yields nothing. Retirement signal, borrowed
313+
from skill-craft's consolidation rule: two consecutive sweeps returning
314+
only minor findings — then the ritual stops until the next burst.
315+
231316
## Adding a check
232317

233318
Two rules, both learned the expensive way:
@@ -242,6 +327,16 @@ Two rules, both learned the expensive way:
242327
*different* bug, already fixed. Re-derive which change produced an
243328
observation before building on it.
244329

330+
**A bite's expected value comes from the invariant's DEFINITION, never
331+
from the implementation or the reasoning that produced it** — an
332+
expectation with the same parentage as the code pins the bug it should
333+
catch. Write the definitional comment first; the assertion follows from
334+
it. (Observed: the succession bite's first draft asserted a
335+
one-shot-sidecar handback as a correct succession — same mental model
336+
as the code's missing first-appearance condition; writing the
337+
definition sentence is what contradicted the assertion, and the
338+
phantom-minting bug fell out of the correction.)
339+
245340
3. **The corpus is blind along its own curation axis.** `harvest.mjs` selects
246341
pairs by *structural novelty* and sanitises them, so the committed fixtures
247342
are small by construction — and therefore a fixture corpus curated for

proxy/source-fingerprint.mjs

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
// Fingerprint of the proxy's own source tree, computed once at startup and
2+
// reported on /health as `proxy_tree`.
3+
//
4+
// Why this exists. Hot-reload is off, so the running process keeps whatever
5+
// code it loaded at start; edit proxy/ without restarting and the repo checks
6+
// pass while the traffic is served by something else. The dotfiles doctor
7+
// asked that question by comparing the newest MTIME under proxy/ against the
8+
// unit's start time, with a comment declaring the label to be the whole truth
9+
// because "what the process holds in memory is not hashable".
10+
//
11+
// It is not the whole truth, and 2026-07-28 showed how: restoring a file from
12+
// a backup after a bite test moved its mtime while leaving the bytes
13+
// identical, and doctor reported "still running old code" about a proxy that
14+
// was running exactly the code on disk. A checker that fires on a non-defect
15+
// trains its reader to ignore it — the same fault, in the same repo, that the
16+
// mtime comment was written to avoid.
17+
//
18+
// What the process holds is not hashable, but what it LOADED is: it can
19+
// fingerprint its own source at startup and publish the result. Then doctor
20+
// compares content to content, and mtime churn is silent by construction.
21+
//
22+
// The algorithm is deliberately dull, because a second implementation would
23+
// have to match it: every regular file under the root except node_modules and
24+
// dot-directories, relative POSIX paths sorted byte-wise, each contributing
25+
// `path\n<sha256 of contents>\n` to one running hash. Nothing here depends on
26+
// filesystem order, mtimes, or inode numbers.
27+
//
28+
// There is no second implementation: doctor shells out to this file rather
29+
// than mirroring it in Python. Two implementations of one hash is exactly the
30+
// kind of duplication that drifts silently and reports a mismatch nobody can
31+
// explain.
32+
33+
import { createHash } from "node:crypto";
34+
import { readdir, readFile } from "node:fs/promises";
35+
import { join, relative, sep, dirname } from "node:path";
36+
import { fileURLToPath } from "node:url";
37+
38+
const SKIP_DIRS = new Set(["node_modules"]);
39+
40+
async function collect(root, dir, out) {
41+
const entries = await readdir(dir, { withFileTypes: true });
42+
for (const e of entries) {
43+
if (e.name.startsWith(".")) continue;
44+
const full = join(dir, e.name);
45+
if (e.isDirectory()) {
46+
if (SKIP_DIRS.has(e.name)) continue;
47+
await collect(root, full, out);
48+
} else if (e.isFile()) {
49+
out.push(relative(root, full).split(sep).join("/"));
50+
}
51+
}
52+
return out;
53+
}
54+
55+
export async function sourceFingerprint(root) {
56+
const files = (await collect(root, root, [])).sort();
57+
const h = createHash("sha256");
58+
for (const rel of files) {
59+
const bytes = await readFile(join(root, rel));
60+
h.update(rel);
61+
h.update("\n");
62+
h.update(createHash("sha256").update(bytes).digest("hex"));
63+
h.update("\n");
64+
}
65+
return h.digest("hex").slice(0, 12);
66+
}
67+
68+
export const PROXY_ROOT = dirname(fileURLToPath(import.meta.url));
69+
70+
// `node proxy/source-fingerprint.mjs [root]` — the form doctor calls.
71+
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
72+
const root = process.argv[2] ?? PROXY_ROOT;
73+
sourceFingerprint(root).then(
74+
(fp) => process.stdout.write(fp + "\n"),
75+
(err) => {
76+
process.stderr.write(`source-fingerprint failed: ${err?.message ?? err}\n`);
77+
process.exit(1);
78+
},
79+
);
80+
}

test/gate-live.test.mjs

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,29 @@ test("replay children run under the heap cap, before the script path", () => {
104104
"cap must precede the script path or node passes it to the script instead",
105105
);
106106
assert.ok(args.includes("CACHE_FIX_PREFIXDIFF=1"), "gate env must survive");
107+
// Census rides every sweep: dropping it silently reverts the row-4
108+
// annotations to on-demand and the daily verdict stops carrying them.
109+
assert.ok(args.includes("--census"), "sweep must run the census annotations");
110+
});
111+
112+
test("BITE — a row that compared zero pairs is marked proves-nothing, never padded into clean", () => {
113+
// c-empty (71 requests, all empty bodies) and single-request captures ran
114+
// ZERO cross-request checks; before this flag they counted toward
115+
// "9 captures clean". Absence of comparison must be visible.
116+
const row = summarise("c-empty.jsonl", 10, json({
117+
report: Array.from({ length: 71 }, (_, n) => ({ n })),
118+
violations: [], safety: [], sequence: [], orderViolations: [],
119+
census: { pairs: 0 },
120+
}));
121+
assert.equal(row.provesNothing, true);
122+
assert.equal(rowIsClean(row), true, "proves-nothing is not FAILING — it is not PROVING");
123+
const real = summarise("s.jsonl", 10, json({
124+
report: [{ n: 0 }, { n: 1 }],
125+
violations: [], safety: [], sequence: [], orderViolations: [],
126+
census: { pairs: 1 },
127+
}));
128+
assert.equal(real.provesNothing, false);
129+
assert.equal(real.pairs, 1);
107130
});
108131

109132
test("nothing comparable is NOT a failure — it is an honest absence of evidence", () => {

0 commit comments

Comments
 (0)