The Door on Pippa's Side
Coop's notifier does not talk to Telegram. It talks to Pippa. cwkPippa exposes a small family of engine doors under /api/coop/engine/ (bind, wake, status and notify), and the last one is how a line from the engine reaches Dad. Its body is strict: text (up to 4,000 characters), a kind, and an optional outbox_id. Any other field is rejected. The door carries the message; it does not own it. The engine's outbox row stays the durable record, which is why a refusal here costs nothing but a delay.
Who May Knock
A door that can ring Dad's phone and write into his conversation is worth guarding, and the guard is layered:
- POST from loopback only. The engine runs beside Pippa on office and dials loopback. The accepted peers default to the two loopback addresses; a tailnet address was once a guess and is not a peer.
- No browser on the line. A request carrying an
Origin, aCookie, anX-Forwarded-Foror a Pippa session header is refused before anything else is checked. A page open in Dad's browser cannot ride his login into this door, and nothing proxied can either. - A service key, compared in constant time. The key travels in a header and is checked with
hmac.compare_digest, so the time a wrong guess takes says nothing about how close it was. On Coop's side, the client refuses to send at all if its key file is readable by anyone but its owner. (Where the key lives and what it is stays out of this course.) - The family's security posture. Pippa asks the family authority for its current state; a blocked or locked posture shuts the door, and an unreadable one answers 503 rather than guessing.
- Dad's context. The route runs only when the current user is Dad.
Two Destinations, in a Fixed Order
A call without an outbox_id has nothing to deduplicate by, so it goes to Telegram alone. Every call from the notifier carries one, and then the order is fixed:
- Master Chief first.
master_chief.reportwrites the text into Dad's Master Chief conversation, keyed by the sourcecoop-notify:<outbox id>. The door waits at most two seconds for it to land. - Telegram second, once. Under a lock held per outbox row, the door checks a small table,
coop_notify_sent. If this row was already sent, it answersalready_sentand rings nothing. Otherwise it sends; a refused send answers 503notification_channel_unavailable, which the engine turns into a deferral; a successful send is marked in the table.
Master Chief is Dad's one continuous conversation with Pippa: the same thread whether he opens the WebUI, the Pippa app or Telegram, never trashed and never filed away. It has its own quest, which covers that conversation itself. What matters here is that a Coop line lands there as a stored assistant row marked turn_kind coop: a system line, not Pippa's own turn. The WebUI and the phone draw it apart from her replies, with a badge where her avatar would be and no Listen control, and voice mode never reads it aloud or takes it for her answer (Coop todo #406, 2026-10-12). It is still part of the conversation's stored history, so the next time Dad talks to Pippa, the report is already in the history she reads. Delegated work reports back into the room where Dad actually is (Dad, 2026-10-08, Coop todo #348).
Durable Before Anything Waits
The interesting part is how report writes into a conversation that may be busy. Dad could be mid-turn with Pippa on his phone when a Coop outcome arrives, and a report must never interleave with a live turn. So report does three things in a deliberate order:
- It
INSERT OR IGNOREs the line intochief_report_outbox, keyedchief-report:plus the source id. From this moment the line survives anything. - It starts (or nudges) the one drainer for that Chief. The drainer takes the Chief's turn lock, appends every undelivered row in order as an assistant message whose external request id is that key, marked as a scheduled-task origin with no tools and with the
turn_kindits source key names (coopfor acoop-notify:line), and records the message id. - It waits, but only as long as its caller allows. The notify door allows two seconds, a cron job five. Past that, the call returns and the row lands when the Chief is free.
Before inserting anything, report checks whether a message with that key already exists and returns it if so. At startup, resume_reports drains whatever a restart left waiting. A report is now idempotent, bounded for its caller, and restart-proof.
The Bug That Rang Twice
It was not always so. The notify door first shipped on 2026-09-30 as Telegram only. Master Chief delivery was added on 2026-10-08, and a cold review the same day (finding #6) found what that addition had done. A report used to wait on the Chief's turn lock inside its caller, with no bound. Dad in a long turn held the notify request open past the engine's 15-second client timeout. The engine saw a failure and retried. Each retry added another waiter. When the Chief came free, every waiter sent its Telegram, and one event rang Dad's phone once per retry. The same shape kept a cron job "running" through a long Chief turn so its next fire was skipped, and a restart lost the report outright, because it lived only in a waiting coroutine.
The fix, commit 5536821e, is everything in the previous section plus the sent-table and lock in this one: write first, wait briefly, ring once per row.
Once, Honestly
Read the door's last lines carefully and you will see that "once" has a precise shape. The sent mark is written after Telegram accepts the message. Retries after the mark ring nothing; concurrent retries inside the process wait on the per-row lock and then find the mark. What remains is a crash between the send and the mark, and the engine's next retry would ring again. That is a choice, not an oversight: Coop's architecture promises at-least-once delivery with deduplication per destination, never global exactly-once. For an outcome Dad needs to know about, a rare second ring is cheaper than a silent first one. Lesson 4 meets a mechanism that chose the other way, and says why.