Repository navigation
[Bug] Host audit log reuses seq on about half its entries when five or more hooks run at once; no entry is lost #250
Description
What is broken
createAuditSink numbers each entry by reading the log, taking the highest seq, and appending seq + 1, with nothing between the read and the append. Claude Code runs hooks in parallel, so concurrent fail-open proceeds reuse numbers: with five or more shims at once, 44 to 56% of entries carried a seq another entry already had. Every entry was still written.
Where
reference-implementations/agt/packages/host-adapter/src/audit-sink.ts (highestSeqIn, write); reference-implementations/agt/README.md "Operational debt"
What the specification says, and what happens instead
The tree already lists this under README "Operational debt":
The audit sequence number can collide. Two Claude Code hooks that run at the same time read the same log length and write the same
seq.
That says it can happen. This measures how often, and checks the part it leaves open: whether parallel writes also lose or corrupt entries, which would break section 6.4's "Every step that proceeds without a decision MUST be recorded as an audit event."
Setup: 27799c2 on integration, Windows 11, Bun 1.3.11 (the CI pin), Guardian not running and port 8787 confirmed closed before each batch, so every shim fell back to the built-in proceed posture (posture_source: default on every entry). K shims started from one parent with Bun.spawn, each running the shipped settings.json command, bun run hosts/claude-code/acs-hook.ts, with the same PreToolUse / Bash payload and the same session_id, as parallel tool calls in one session would carry. Each trial wrote to its own ACS_AUDIT_LOG.
Control first: 10 shims one after another, three trials. seq came out 1 to 10 exactly every time.
Parallel, 10 trials at each K:
| K | trials with a repeated seq |
entries repeating a seq already used |
entries missing | entries not valid JSON |
|---|---|---|---|---|
| 2 | 2 of 10 | 2 of 20 | 0 | 0 |
| 5 | 10 of 10 | 22 of 50 | 0 | 0 |
| 10 | 10 of 10 | 56 of 100 | 0 | 0 |
| 20 | 10 of 10 | 97 of 200 | 0 | 0 |
"Repeating" counts parseable entries minus distinct seq values. The collisions are many-way: in one K=20 trial ten entries all carry seq 11, and in three K=5 trials five entries top out at seq 2.
What held: all 370 parallel shims exited 0 and printed the same fail-open allow as the sequential control, byte for byte. Each wrote exactly one entry. All 370 entries are valid JSON with distinct rpc_ids and read outcome: proceeded, failure.kind: transport, session_failure.kind: handshake_failed. So the 6.4 MUST holds under this load; only the number is wrong.
Control with the Guardian up: 5 trials of 10 parallel shims, 50 of 50 printed the documented allow and no trial wrote an audit entry, so the launcher is not what produces the result.
This is the same class of race #180 closed for the session store (read, then write, with another hook's write landing in between), in the audit sink instead.
Impact on implementers
Nothing on screen miscounts today: the Inspector's fail-open badge counts entries, and its tail follows byte offsets, not seq. What breaks is anything that treats seq as unique or reads the highest seq as a count, which gives 2 for five recorded proceeds. The thread on #37 proposes deriving the fail-open count from a counter that never repeats; the one counter the reference host ships repeats under the parallelism Claude Code produces.
The fix sits in the host adapter, not in AGT, so it is a code change rather than a gaps-table entry: number entries under a lock, or stop presenting seq as an ordinal and key entries on rpc_id plus recorded_at.
Not measured: real Claude Code scheduling (parallel hooks were approximated with Bun.spawn from one process), PostToolUse, other failure kinds, the deny posture, Linux or macOS, more than 20 at once. The launcher and analysis scripts are about 200 lines together; I can attach them.
Prem Methuku
Current Priority Scope
Feeds the runnable Guardian reference implementation
Metadata
Metadata
Assignees
Labels
Type
Projects
- StatusShow more project fieldsNeeds triage
Activity
4ktLuffy commentedon Oct 31, 2026
I tested this on macOS arm64 at b383e40 and it's correct, the collisions reproduce. Nothing is lost and every entry is valid JSON; the sequential control gives 1..10 every time.
One thing is off for macOS though: two hooks are already enough, and the rate doesn't grow with K. Four runs, 10 trials per K, Guardian down (trials with a repeat / entries repeating a seq):
So "five or more" holds for Windows, not here. I didn't run the Guardian-up case.
To recheck, from
reference-implementations/agtwith nothing on 8787:bun run probe250.ts 10 2,5,10,20probe250.ts