State Is a Field; Transitions Are Verbs
Once an item has an identity and a state column, the next question is which changes of state are allowed and who may make them. Coop answers with two small lifecycles:
- todos:
open → taken → done | abandoned, withtaken → openthrough a release. - requests:
open → taken → closed, with the same release back to open.
The status column on both tables is plain TEXT with no CHECK. That is deliberate — "convention, not schema, like brain IDs", in the design document's words — so the rules live where Lesson 2's roster lives: in the helper functions every write passes through. Each verb — take, release, done, abandon, close — is a function that checks the current state, makes the change, and appends one row to coop_events. Nothing deletes from that table. The board shows you where everything is; the event log shows you how it got there.
One more line from the architecture is worth holding onto: keep transport states out of legacy status columns. Whether a message was delivered, whether a session is alive, whether a turn is running — none of that belongs in status. A todo can stay taken across several turns and restarts. The status says whose work it is, not what the plumbing is doing.
The Fast Path Stays Legal
A lifecycle with a mandatory taken step sounds rigorous. The design document refuses it in one sentence: "A 5-minute todo must not cost 3 CLI calls; ceremony killed letter compliance once already." So open → done (and open → closed) is legal in one motion: an implicit take and done together.
This is not laxity. A ledger that costs more than the work gets skipped, and a skipped ledger is worse than one with a missing intermediate state. The taken state exists for work long enough that someone else might pick it up in the meantime. For a two-line fix, the risk window is the fix itself.
Take Is One Guarded UPDATE
Lesson 1's sketch showed the idea; here is how the engine does it. take_todo reads the row first, and that read does two jobs: a terminal row refuses (terminal rows are never re-taken), and a row already taken by someone else refuses, naming the holder, unless the caller passes --force. The claim itself is a single statement:
UPDATE todos SET status = 'taken', taken_by = ?, … WHERE id = ? AND (status = 'open' OR taken_by = ?)
Then it checks rowcount. Zero rows means the row changed between the read and the write, and the error says exactly that: changed under this take — re-read and retry. Whether the row is still open, or still yours, is decided in the WHERE clause, where the database evaluates it atomically. Re-taking your own row matches the taken_by = ? half and just refreshes taken_at.
Look hard at that half, though. A todo you finished is done and still carries your name in taken_by, so the WHERE clause on its own would match it and quietly turn your finished work back into taken. The terminal check in the earlier read is what stops that, so the read is load-bearing, not decoration. It is safe because the engine runs the read and the UPDATE inside one BEGIN IMMEDIATE transaction, and no other writer can touch the row between them. A check in Python is sound exactly when it runs inside the write transaction; between a SELECT and an UPDATE that are not in one, it decides nothing.
A forced takeover is allowed, because sometimes the previous holder really is gone. But it is never quiet: the event is written as takeover rather than take, with a payload naming the previous holder and when they took it. The same guards apply to finishing: done, abandon and close by anyone other than the taker refuse without --force.
The First Close Is the Record
Until 2026-09-29, running done on a todo that was already done simply closed it again. People did it on purpose — it was how a commit hash got backfilled or a summary corrected; a census found nine rows touched that way. It also let a second brain overwrite who closed the row and when. Todo #154 and request #189 had their closer rewritten, and downstream readers counted a second completion that never happened.
Commit 6c4a669 made the first close the record. A second done or close now routes to _amend_outcome: it may only fill in or correct result_summary and commit_sha, it writes an edit event naming the amender and keeping the previous values, and an identical repeat writes nothing at all. The closer, the close time and the state never change. An abandoned todo stays abandoned; only Dad, with --force, may record it done after all, and that done event carries previous_status so the reversal is visible forever.
No Auto-Reap, Ever
Every queue system eventually meets the stale claim: a todo marked taken hours ago by a session that has gone quiet. The tempting fix is a timer that puts it back. Coop refuses: "No auto-reap, ever." Stale takes surface in listings as [taken by X, Nh ago] — visible, never auto-reverted.
The reason is that a timer cannot tell a dead session from a slow one. Release a take that is merely long, and a second session starts the same work while the first is still writing to the same files. Two workers on one item is exactly the failure the take was built to prevent. So releasing someone else's stale take is a decision with a name on it: Dad's, or, since 2026-09-30, the Coop chief's, acting as his proxy (Track 6). Either way it goes through release with a reason, and the event log says who decided.