Two Kinds of Mail in One Outbox
Everything Coop sends goes through one table, engine_outbox. Each row has a kind, a JSON payload, a state, an attempts counter, a due time, and one column that decides who the row is for: session_id.
- A row with a session is mail for a worker. Invitations, work envelopes, reply reviews, follow-ups and notices all carry the exact session (and generation) that must receive them, and that session's adapter is the only thing that delivers them. Track 4 followed those through the native doors.
- A row with no session is mail for Dad. There is no native receiver to hand it to, so it waits for a different reader.
The architecture states the second kind as a list: an outcome a worker or the chief recorded, a blocked report, a declined or expired invitation, a lost worker session, a chief escalation or park. In code those are two kinds. outcome rows carry done, released or abandoned. attention rows carry a reason: blocked_report, invitation_declined, invitation_expired, session_disconnected, session_exited, chief_escalation, chief_park, plus a few the assignment flow adds (a failed or expired setup, a remote session that is ready, a CLI take that was adopted, a worker's reply to Dad). There is no separate notifications table and no send_to_dad() anywhere in the domain. Dad's notification is just an outbox row nobody native will ever claim.
Written in the Same Transaction
Look at where an outcome notification is born. When a report closes work, _record_outcome runs inside the engine's command transaction and does five things in a row: insert the report, mark the binding resolved, cancel any pending reply review, resolve the outcome obligation (closing the dispatch if nothing else is bound), and enqueue the outcome row for Dad. One BEGIN IMMEDIATE, one commit.
That ordering is the whole point. The alternative, closing the work and then calling Telegram, has two crash windows. Crash after the close and before the call, and Dad never hears that the work finished. Call first and crash before the commit, and Dad hears about a close that never happened. Writing the notice into the same transaction as the change it announces removes both: either the close and its notice exist together, or neither does. This is the transactional outbox pattern, and Coop's architecture names it plainly: canonical mutations write their outbox entries in the same transaction.
The Notifier: Fifteen Seconds, Ten Rows
The reader is Notifier, a small task in Coop's chief.py. Every 15 seconds it selects session-less rows that are pending and due, oldest first, at most ten. For each it composes Dad's text:
- A head line:
[Coop] <head>: <label>. The head is a short word with an emoji (✅ done,⏸ worker blocked,⌛ invitation not accepted,🅿️ parked for Dad,👀 chat enrolled). The label is the work item, read fresh from the ledger:todo #N (scope) title. - Below it, the human lines.
dad_texttakes the worker's or chief'sfor_dadlines when they exist, cut at 1,200 characters (FOR_DAD_LIMIT). Without them it falls back to an excerpt of the English report body, with any pasted report JSON and hidden report lines stripped out. The code comment says it exactly: the English excerpt is the fallback, not the message. Report JSON never reaches Dad, whatever a worker pasted.
Track 5 showed how for_dad gets into a report. This is where it comes out.
Failure Defers; It Does Not Drop
The composed text goes to Pippa's notify door (the next lesson). If Pippa answers, the engine settles the row as delivered with a receipt naming the channel. If anything goes wrong, the row is deferred: attempts goes up by one and due moves to now plus min(300, 2 ** min(attempts, 8)) seconds. The waits double from two seconds; the code writes the ceiling as 300, but the exponent stops at 8, so the longest wait is 256 seconds. With quick failures and a 15-second tick, twelve attempts take about twenty-four minutes, and the twelfth marks the row failed. (Worker-bound rows give up after five.)
Read "a Pippa outage delays a notification and never loses it" with that in mind. A restart or a short outage costs Dad a few minutes. A long one turns the row failed, and it does not vanish. The row stays in the store; for the week after it was created, Coop's Operations view lists it as could-not-be-delivered with the attempt count and the last error, and the workspace badge counts it among the things that need Dad. Nothing deletes it and nothing retries it forever. Bounded and visible, never silent.
One more edge, for completeness. Maintenance expires a session-less row that is still pending three days after it was created, recording stale-before-delivery. With a running notifier that cannot happen to a live row; it exists for the case where no reader ran at all, which is exactly the case Track 6 found.
What Never Becomes a Notification
Just as important is what is not in that list. A session going idle is not news. A repeated observation is not news. A worker's prose final without a report becomes a note for the chief, not a ping. And a native version change is recorded on the session row and never announced. That last one was learned the hard way: the Claude adapter once carried a version allow-list, and as a gate it blocked every assignment; demoted to a note, it still read to Dad like a block. On 2026-10-02 it was removed. Dad hears about decisions, losses and things only he can unblock, not telemetry.