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
e0e9e9cwidened a singleVERSION = '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.