Skip to content
C.W.K.
Stream
Lesson 04 of 04 · published

Hooks Fail Open; Versions Are Notes

~15 min · hooks, fail-open, admission-control, versioning, observability

Level 0Loose Note
0 XP0/36 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete

The Gate Everyone Reaches For

If you want a worker to claim a task before it starts, the obvious tool is a hook. Claude Code runs a UserPromptSubmit hook before a prompt is processed and a PreToolUse hook before each tool call; make the hook check the ledger, and refuse unless the claim is recorded. A gate at the door. It is so obvious that Coop's first milestone measured it before building anything on it.

The fixtures used a one-second native hook timeout, and timeout mode deliberately waited three seconds at the chosen event. Each run asked for one Read of a fixture file. The full table is in the first code block below; here is the short version.

An explicit denial worked: exit code 2 stopped the prompt or the tool, and no Read happened. A hook that simply did not answer in time did not stop anything. The prompt went through, the Read succeeded, the reply matched. Claude Desktop's own prompt hook reproduced the same thing in a real Desktop session. One more detail from the same table is worth keeping: a denied prompt still ended with process exit 0 and is_error false, so even a successful exit was not evidence that the work ran.

The conclusion went into the architecture verbatim: a hook is an observation and an explicit denial mechanism, not an unconditional admission barrier. A hook that disappears or times out cannot enforce a claim. The Codex side added its own version of the lesson: both installed Codex binaries listed a fixture hook as enabled=true and trustStatus=untrusted, so a hook appearing in an inventory says nothing about whether its callback will run. The implementation plan's rule follows: a hook that fails open must never be advertised as enforcement.

Withhold the Payload Instead

If the door cannot be locked, do not leave anything worth stealing on the doorstep. That is the whole design. A plain invitation carries a dispatch id and a one-time nonce, and no task text at all. Its prompt says so in plain words: no task payload has been released yet; do not use tools or perform work; reply COOP_ACCEPT with the nonce; the engine will commit the claim before sending the task.

One case is wider, and you should know it exactly. Since 2026-10-05, when a todo already has replies, its invitation also carries the todo body and every reply, because accepting means acknowledging those exact replies. That prompt is different: review the body and every reply, do not use tools or perform the task yet, and the engine will commit the claim before sending the work bindings. So here the worker can already read what the task is. What it still does not have is the work itself: the binding, the scope and the go-ahead.

When that accept arrives, Engine.accept takes every item, inserts the bindings and the outcome obligations, marks the dispatch accepted, and enqueues the work envelope, all in one transaction. The work envelope exists in the outbox only after the claim is committed. Coop's capability notes, written at the first milestone, put it in one line: If a command hook fails open, it still has no task payload to release. For a plain invitation that is still literally true. For a todo with replies, the body is already on screen, so what holds there is narrower: an instruction not to start, and a work envelope that does not exist yet. The milestone's admission table checked the bad paths on both Desktop apps: with the store locked, the work stayed open, the payload was withheld and nothing was claimed; with the adapter stopped right after the claim, the work stayed taken with exactly one pending obligation and still no payload sent.

Vendors move, and Claude Code's hook documentation has moved too. It now says plainly that a stalled PreToolUse hook should not be counted on as a gate, and it offers an onFailure: "block" option from version 2.1.295. That is a welcome knob, and Coop does not need it. A guarantee that holds on every version beats one that holds from a certain version on.

The Version Gate That Became a Note, Then Nothing

Coop's first plan also pinned vendor versions: Claude's peer frame was version-bound, so the adapter would fail closed on any receiver build it had not measured. Here is how that went.

  • 2026-09-29. Claude Code updated to 2.1.284, and the allowlist blocked every assignment. Commit e0e9e9c widened a single VERSION = '2.1.281' to a set holding both builds.
  • The gate was then softened into a note: an unmeasured build still enrolled and delivered, and the adapter told Dad it had not been verified.
  • 2026-10-02. Claude Code 2.1.286 arrived. Every new chat showed a compatibility message, discovery labeled every chat Unverified version, and Dad read both as Coop blocking work. Nothing was gated; 2.1.286 assignments had resolved all day.

Commit c816c11 removed both allowlists and the note. Its message is the lesson: "As a gate they blocked every assignment (2.1.284); as a note they still read like a block. They are gone." The version is now recorded on the session row and shown on its card, never judged. What protects delivery are the checks that look at behavior, not labels: the peer protocol number, the private inbox socket, exact identity, and a receipt checked for every frame. If an update breaks delivery, it shows up as a visible non-delivery from the previous lesson's machinery, which is a much better alarm than a version string that changes every week.

The Supervisor Is Also Just Watching

Each enrolled chat gets its own adapter child process on office, which runs a loop in adapters/__main__.py. While a delivery is in flight it polls every 2 seconds; when nothing is waiting, or the chat is unreachable, every 15. Those numbers came from the 2026-09-30 review, after every adapter had been polling at 2 seconds around the clock.

If the engine shows the chat idle for more than six hours, the child asks the engine to retire the enrollment with the reason Idle for six hours with nothing owed; a new assignment enrolls the chat again. The engine refuses if the session still owes anything. If the chat itself has ended, the same rule applies: retire only when nothing is owed, otherwise keep the enrollment and keep watching. Any other failure turns into a disconnected observation and a retry, never into closing work or changing its brain.

That is invariant 4 of the architecture: lifecycle observations are evidence, not work outcomes. In the engine, observe_session can do exactly one thing to work: pull a pending obligation's review time forward. A repeated status changes nothing, which matters because five of the chief's earliest wakes were adapters restarting and reporting the status the engine already had. A transition to idle asks for a review only when there is something the chief has not seen yet: no review so far, or a note the worker left since the last one; a transition to disconnected or exited asks at once and tells Dad. No observation closes, releases or reassigns anything.

Do not build a guarantee on a mechanism that can fail open; build it on an ordering. A hook can time out, a version string can change, a status can repeat. Payload after commit, receipt before acknowledgment, decision after evidence: an ordering holds no matter which observer happens to be late.

Code

Coop's M1 hook probe, one-second hook timeout (table from the probe report)·text
| Case        | Target hook      | Hook result                            | Successful Reads | Matching reply |
|-------------|------------------|----------------------------------------|-----------------:|----------------|
| Observe     | UserPromptSubmit | Normal entry/exit                      | 1                | Yes            |
| Deny prompt | UserPromptSubmit | Exit 2                                 | 0                | No             |
| Delay prompt| UserPromptSubmit | Entry, no exit before native timeout   | 1                | Yes            |
| Deny tool   | PreToolUse       | Exit 2                                 | 0                | No             |
| Delay tool  | PreToolUse       | Entry, no exit before native timeout   | 1                | Yes            |
adapters/__main__.py: polling and idle retirement (verbatim excerpts)·python
# An enrollment that has owed nothing for this long ends by itself; the next
# assignment to the chat enrolls it again at a new generation. Until then a
# child would poll a chat nobody is using.
IDLE_RETIRE_SECONDS=6*3600
# Polling: a session with work in flight is read every 2 s; an idle or
# unreachable one every 15 s.
ACTIVE_INTERVAL=2
QUIET_INTERVAL=15

# ... inside serve() ...
            if state=='idle' and not once:
                row=(engine.snapshot()['tables']['engine_sessions'] or [{}])[0]
                since=row.get('status_since')
                if row.get('status')=='idle' and since and time.time()-since>IDLE_RETIRE_SECONDS:
                    try:
                        engine.retire('retire-idle:%s:%s:%d'%(session['id'],session['generation'],int(since)),session['id'],session['generation'],
                                      'Idle for six hours with nothing owed; a new assignment enrolls the chat again')
                    except Conflict as owed:
                        log(session_id=session['id'],state='idle_retire_refused',detail=str(owed)[:300])
engine.py observe_session: an observation only moves a review earlier (faithfully trimmed excerpt)·python
            changed=session['status']!=status
            ...
            if changed and status in ('idle','disconnected','exited'):
                active=[dict(r) for r in conn.execute("SELECT * FROM engine_bindings WHERE session_id=? AND state='active'",(session_id,))]
                for binding in active:
                    obligation=conn.execute("SELECT * FROM engine_obligations WHERE binding_id=? AND status='pending' AND parked=0",(binding['id'],)).fetchone()
                    if not obligation:
                        continue
                    if status=='idle' and obligation['reviewed'] is not None:
                        fresh=conn.execute('SELECT 1 FROM engine_notes WHERE dispatch_id=? AND created>? LIMIT 1',
                                           (binding['dispatch_id'],obligation['reviewed'])).fetchone()
                        if not fresh:
                            continue
                    conn.execute('UPDATE engine_obligations SET due=MIN(due,?) WHERE id=?',(now,obligation['id']))
                if active and status in ('disconnected','exited'):
                    self._enqueue(conn,None,None,'attention',{'reason':'session_'+status,'session_id':session_id,
                        'items':[{'kind':b['kind'],'id':b['item_id']} for b in active]})

External links

Exercise

List every gate in a system you run that is supposed to stop something from happening: a pre-commit hook, a CI check, an authorization middleware, a webhook validator. For each one, find out what happens when the gate itself times out, crashes or is missing. Then pick one that fails open and redesign it so the protected thing is withheld until a committed record exists, rather than blocked by the gate.
Hint
The quickest way to find out is to make the gate sleep longer than its timeout, or rename it so it cannot be found, and run the action. If the action still happens, the gate is an observation. The redesign usually moves the valuable part (a secret, a payload, a deploy artifact) behind a step that only runs after the record is committed.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign in — Please sign in to comment.

No comments yet — be the first.