sanctum-msg-bus

A notification fabric that doesn’t lie.
The bug class it solves
Section titled “The bug class it solves”For eight days in May 2026, signal-cli on the Mini was dead. Every Force Flow call to send_signal(...) returned without raising, the operator’s daily P2 digest was silently dropped, and the only outward signal was a 30-min-cooldown signal-health sentinel that fired an HA-push alert about signal being down — the irony fully intentional in retrospect, fully invisible at the time.
The deeper failure was structural, not incidental:
- Zero message persistence. Every
/notifywas a fire-and-forget HTTP call. If the channel returned 500, the alert was lost. - Zero delivery acknowledgment. Force Flow recorded
iphone_criticalas “sent” becauseresp.status == 200. Transport-layer success ≠ delivery success. - Channels fired in parallel, not in failover. P0 =
[iphone_critical, yoda_call, dashboard]— all three at once. If the one the operator was watching failed, the message was lost despite “three channels”.
Adding more channels was making the noise worse without making the system reliable. The architectural answer was a single bus that owns the failover.
The architecture
Section titled “The architecture” /enqueuesources ───────────────► sanctum-msg-bus │ ┌───────────┼───────────┐ ▼ ▼ ▼ iMessage HA-push Signal (primary) (fail-2) (fail-3) │ │ │ ▼ ▼ ▼ chat.db HA REST signal-cli ack-poll 200=ok RPC-accept- Daemon: Python, aiohttp on
127.0.0.1:4076. Lives at~/.sanctum/msg-bus/. Launchd-managed ascom.sanctum.msg-bus. - Queue: sqlite WAL at
~/.sanctum/msg-bus/queue.db. Tables:messages(one row per enqueue),attempts(one row per send attempt + ack). State machine:pending→in_flight→delivered | failed. Survives daemon restart. - Dispatch policy: per-severity failover lists in
config.yaml. P0 cascades through every wired transport; P1 stops at HA-push; P2 is iMessage-only. A fourth tier (SMS via voip.ms) and quiet-hours digest batching are written into the policy but not yet wired — see What’s not wired yet. - Transports: pluggable. Each implements
send → SendReceipt,poll_ack → AckState,healthz → HealthState. The bus dispatcher iterates the failover plan, waits for ack within the per-step timeout, escalates on no-ack. Three are wired: iMessage and HA-push have true delivery confirmation; Signal is RPC-accept only in v0.1 (a successful JSON-RPC return counts as delivered — a realMessageDeliveredEventsubscription waits on pinning the signal-cli daemon to a known version). - API (three endpoints, all on
127.0.0.1:4076):POST /enqueue— body:{audience, severity, title, body}→ returns{msg_id}GET /msg/{id}— full lifecycle (every attempt + ack + final state)GET /healthz— queue depth + per-transport last-success + last-error
The iMessage transport, in detail
Section titled “The iMessage transport, in detail”The iMessage transport is the one that exists because the operator wanted apple-like. It sends via osascript to Messages.app and polls ~/Library/Messages/chat.db for delivery + read receipts. Two interesting bits surfaced during the first hour of live operation:
chat.db stores multi-line / emoji / rich bodies in attributedBody (NSKeyedArchive blob), not text. The first version of _find_sent_rowid searched WHERE text = ? and missed every non-trivial message. The fix: snapshot the highest is_from_me=1 rowid for the recipient before the osascript send, then poll for any new rowid > snapshot. Text-agnostic, attributedBody-agnostic, works for everything Messages.app accepts.
FDA-graceful degradation. If ~/Library/Messages/chat.db is unreadable (the launchd-spawned daemon hasn’t been granted Full Disk Access), the post-send rowid lookup raises. The naïve handler would mark the attempt failed and the bus would failover to HA-push — and the operator would get two notifications for every alert. The graceful handler treats osascript-send-success as evidence Messages.app accepted the dispatch, returns SendReceipt(handle="no-fda"), and poll_ack short-circuits to delivered with an audit note. Degraded ack-confidence (no delivered/read receipts) in exchange for transport stability. Grant FDA when convenient and the same code-path upgrades to true delivery-tracked iMessage.
The TCC layer that makes the FDA-or-no-FDA decision durable is documented separately under TCC Identity Anchors.
What “ack-tracked” buys you in practice
Section titled “What “ack-tracked” buys you in practice”The first live failover is still sitting in the queue as message 2. A P1 enqueue attempted iMessage, the osascript send succeeded but the chat.db row never appeared (the launchd-spawned daemon was missing Full Disk Access), so the attempt failed after ~7 s, and the dispatcher moved to HA-push, which delivered in ~1 s. The operator got their phone notification. The /msg/2 lifecycle JSON recorded both attempts, with timestamps and the exact failure mode — verbatim, "osascript send succeeded but chat.db row never appeared (FDA?)". Total operator-visible disruption: zero.
The same alert under the old fire-and-forget routing would have logged iphone_critical sent OK and disappeared into the void.
What’s deliberately not in the bus
Section titled “What’s deliberately not in the bus”- Dashboard, Sonos TTS, macOS Notification Center, plain logs. These are broadcast-only — no per-message ack semantics. Force Flow fires them directly. The bus only owns the channels where delivery confirmation actually matters.
- Voice (yoda_call) end-to-end audio. That’s a LiveKit workstream. The bus enqueues an intent; the LiveKit pipeline runs the call.
- Bert ↔ Yoda inbound chat. Coming in Phase 3 as
sanctum-msg-bridge— a chat.db poller that watches the operator’s self-chat and routes new messages to Yoda for processing. Same chat.db pattern as the iMessage transport, in the reverse direction.
What’s not wired yet
Section titled “What’s not wired yet”The bus runs, the queue persists, and the iMessage/HA-push/Signal transports dispatch with the failover and ack-tracking described above. Three things the policy file anticipates are written down but not yet live — flagged here so an operator reading /healthz isn’t surprised by the gaps:
- Force Flow doesn’t produce to the bus yet. Today
force_flow.pystill fans out to channels directly viadoctrine_route_channels(); nothing POSTs to/enqueue. The bus’s only producers so far are smoke tests and hand-rolledcurl. Until the producer side is wired, the bus is a proven receiver waiting for live traffic — and retiring it changes nothing about how notifications actually flow. - The SMS / voip.ms tier is policy-only.
config.yamllistsvoipms_smsas the P0 fourth step, but there’s novoipms_sms.pytransport and the entry isenabled: false. At runtime the dispatcher hits the missing transport and skips it, so P0 today cascades through three transports, not four. - Quiet-hours digest batching is half-built.
dispatch.pyknows how to hold P2 until morning whenquiet=True, but the live dispatcher always passesquiet=Falseand there’s no digest flusher to release held messages at 07:00. So P2 fires immediately, day or night.
None of these are bugs in the parts that ship. They’re the honest edge of a Phase 1+2 daemon — the diagram drawn one tier ahead of the wiring.
Operating notes
Section titled “Operating notes”- Look at the queue first:
curl -s http://127.0.0.1:4076/healthz | jqshows queue depth and per-transport health. Queue depth > 50 sustained means a transport is degraded. - Read a message’s lifecycle:
curl -s http://127.0.0.1:4076/msg/<id> | jqfor the full attempt log. Useful when an alert didn’t arrive — you can see whether iMessage was even tried or whether the dispatcher failed over silently. - Logs:
~/.sanctum/logs/msg-bus.{stdout,stderr}.log. Restart:launchctl kickstart -k gui/$(id -u)/com.sanctum.msg-bus. - Retire:
launchctl bootout gui/$(id -u)/com.sanctum.msg-bus+ rename the plist.retired-YYYY-MM-DD. Notifications are unaffected — Force Flow still dispatches per-channel directly (see What’s not wired yet), so until/enqueuehas live producers, retiring the bus is a no-op for delivery.
Spec + plan
Section titled “Spec + plan”- Spec:
docs/superpowers/specs/2026-05-23-sanctum-msg-reliability-design.mdin the workspace - Implementation plan (Phase 1+2 task-level, Phase 3-5 sketched):
docs/superpowers/plans/2026-05-23-sanctum-msg-bus.md