#3418 · playground · 2026-08-12

The contract gate checked one direction only.

The drift check in derive-cc-output-keys.mjs failed when the CC binary named an event the allow-list lacked. It said nothing when the allow-list asserted an event the binary never named — 6 of 9 additionalContext events were exactly that, one of them (PostCompact) independently known to be wrong. This PR makes the gate bidirectional, with a reviewed-exception mechanism for the events since settled by live observation, and fixes the second bug the issue surfaced along the way: PostToolUseFailure's handler was reading a field CC never sends.

Try it: the gate, live output

Before · missing-only
After · bidirectional
allow-list: 9 asserted / 3 corroborated by the binary / 6 unverified
WARN: no binary string names these events as additionalContext
consumers; the missing-only check above cannot see them (#3418):
  PostCompact
  PostToolUse
  PostToolUseFailure
  PreToolUse
  SessionStart
  UserPromptSubmit

OK: every event the binary names is present in the generated allow-list.
exit=0   ← green, despite 6 unproven assertions
allow-list: 9 asserted / 3 corroborated by the binary / 4 reviewed exception / 2 unreviewed

CONFIRMED by trace-and-observe, not binary prose (reviewed exception,
see spec/cc-output-keys.spec.yml additionalContext.reviewed_exceptions):
  PostToolUse
  PreToolUse
  SessionStart
  UserPromptSubmit

DRIFT: no binary string names these events as additionalContext
consumers, and they carry no reviewed exception (#3418):
  PostCompact
  PostToolUseFailure

exit=3   ← red on real, still-unproven entries

Both panels are the script's actual stdout, captured 2026-08-12 running node scripts/derive-cc-output-keys.mjs --check against the real CC 2.1.228 binary on this machine — not a mockup.

The escape hatch has a bar, not a switch

A blanket exception for all 6 uncorroborated events would just move the false-signal from "gate is silent" to "gate is silenced" — the issue explicitly warned against pruning or excepting on documentation-prose absence alone. Only events settled by trace-and-observe (emitting hook → output builder → hookSpecificOutput.additionalContext → observed arriving in the model) qualify. Four did, during the issue's own comment thread:

PreToolUse reviewed

Builder outputAllowWithContext (lib/output.ts:304-311) · emitting hook pretool/bash/gh-milestone-enforcer.ts:49 · observed: the "No --milestone set" hint arriving live.

UserPromptSubmit reviewed

Builder outputPromptContext (lib/output.ts:195-196) · emitting hooks prompt/visual-style-nudge.ts, prompt/multi-step-task-nudge.ts · observed: the nudge lines arriving live.

SessionStart reviewed

Builder outputWithContext (lib/output.ts:257-258) · emitting hook lifecycle/sync-session-dispatcher.ts · observed: the CLAUDE_PLUGIN_ROOT= line arriving live.

PostToolUse reviewed

Emitting hook posttool/write/debt-marker-tracker.ts: a file with a real debt marker was written as an active experiment; the advisory came back attached to that exact write.

PostCompact still fails

Registered, but #3321 has independent evidence CC ignores additionalContext on it — a live no-op: because it sits in the allow-list, the #1794 output guard doesn't strip it, so the hook emits into a void.

PostToolUseFailure still fails

Its own emitting hook (failure-handler.ts) was reading a field CC never sends — see below — so the event was unobservable regardless of CC support. Fixing the field read makes it observable; it hasn't been traced-and-observed yet, so it stays out of the reviewed set on purpose.

Bug two: the field nobody sends

failure-handler.ts built its entire suggestion logic on input.tool_error, which is always undefined — so every real tool failure fell through to outputSilentSuccess() with no error message to match against. Decompiling the shipped CC 2.1.228 binary's PostToolUseFailure payload builder:

let p = {
  ...zh(s, void 0, o),
  hook_event_name: "PostToolUseFailure",
  tool_name: e, tool_input: r, tool_use_id: t,
  error: n,        // ← the real field
  is_interrupt: i,
  duration_ms: c
};
fieldfailure-handler.ts readCC actually sends
error messageinput.tool_errorinput.error

subagent-stop/retry-handler.ts already read input.error || input.tool_error — the correct field was one hook away the whole time. types.ts now documents tool_error as never sent and error as the shared, verified field across SubagentStop/StopFailure/PostToolUseFailure.

Verification

src/hooks vitest (full suite)     7566 passed, 2 skipped, 314 files
failure-handler.test.ts           16 passed  (fixtures updated: tool_error → error)
output-guard-cc-contract.test.ts  7 passed   (unaffected — different Sets)
typecheck (src/hooks)             0 errors
npm run build (root)              clean, plugins/ regenerated, no count drift
test:manifests                    passed
derive-cc-output-keys.mjs --check exit=3  (real DRIFT now visible, was exit=0)

The DRIFT is intentional, not a regression. cc-contract-probe.yml is a scheduled, non-blocking workflow (never wired into ci.yml) that now fails loud on PostCompact and PostToolUseFailure until each is settled by the same trace-and-observe method as the four above — exactly the "make the asymmetry visible rather than silent" the issue asked for, now enforced instead of merely printed.