Skip to content

Truthful send states, hard channel contract, and outbox safeguards - #166

Open
MaxGhenis wants to merge 1 commit into
mainfrom
fix/truthful-send-pipeline
Open

Truthful send states, hard channel contract, and outbox safeguards#166
MaxGhenis wants to merge 1 commit into
mainfrom
fix/truthful-send-pipeline

Conversation

@MaxGhenis

Copy link
Copy Markdown
Owner

Rebuilds the send pipeline's reporting and safeguards after the 2026-08-05→06 incident: send_message returned {ok:true, settled:true, state:"confirmed"} for a message that did not reach the recipient until ~15 hours later — seconds behind a manual day-of retry, double-texting the recipient — while send_to_conversation on a whatsapp:* JID returned a bare HTTP 404 even though get_status showed whatsapp connected:true and v2_send:true, and resolve_contact_routes said sendable:false. Three surfaces, three answers, and a success receipt that was a claim about the local ledger, not the world.

1. Truthful send states

Every durable send result now reports transport_state:

transport_state meaning
queued has not left this machine (queued/dispatching/not_dispatched)
transmitted the platform transport acknowledged it (remote message ID). Not proof of delivery
delivered a delivery/read receipt for it was observed in the local store
uncertain outcome unknown; reported as settled:false + uncertain:true (previously counted as settled)
failed / canceled terminal, nothing was sent

settled and transmitted are true only on transport acknowledgment (confirmed/store_failed). The "Message delivery confirmed" wording is gone everywhere (MCP text, CLI); transmitted results carry explicit "transport acceptance is not delivery" guidance. Results include the platform actually used (sms/whatsapp/signal — RCS is not distinguishable at this layer and is deliberately not guessed) and the conversation_id written to. The daemon's v1 delivery responses carry account_id/conversation_id/platform/expires_at_ms/expired so the MCP client can relay them.

2. Hard channel contract

  • The requested platform is enforced at send time against per-platform send capability (see §3): a hard-down platform (unpaired, adapter unregistered/receive-only, auth revoked) fails with the reason and queues nothing — there is never a fallback to another channel. A transient disconnect (queueable) still queues, truthfully reported and bounded by the TTL.
  • send_to_conversation / send_media_to_conversation gain an optional platform assertion that fails on mismatch (platform_mismatch) instead of sending on an unintended channel.
  • platform: "imessage" gets an explicit "import/read-only, cannot send" refusal.
  • The WhatsApp 404 is now self-explanatory: "the app could not resolve conversation X in its serving store … the connection can be up for receiving while this send path has no usable conversation record", instead of a bare HTTP 404: not found.

3. Status honesty

/api/status publishes a send block — send.{sms,whatsapp,signal} with available / queueable / reason — computed in the new internal/sendcap package from transport snapshots (paired, connected, needs_repair, auth_expired, phone_responding, needs_reauth) plus the v2 adapter registry (TextSend). get_status renders it in both serve modes and its text now states that connected/v2_send alone never imply a platform can send. resolve_contact_routes sendability, get_status, and send-time enforcement all read the same source (daemon truth in client mode — previously the MCP client judged sendability from its own transportless, always-disconnected process state), and routes carry a sendable_reason.

4. Outbox safeguards

  • Send window (TTL). New expires_at_ms on the outbox (migration 0011): an intent still queued when its window closes is canceled (error_class:"ttl", surfaced as expired:true / "NOT SENT") instead of transmitting stale. The lease query independently excludes expired rows, so a sweep/dispatch race can never transmit one. MCP sends default to 10 minutes (ttl_seconds per call, OPENMESSAGES_SEND_TTL_SECONDS per install, 0 = never expire); app/UI sends are unchanged (no TTL unless requested). Scheduled sends measure the window from their not_before time.
  • Near-duplicate guard. A text ≥0.75-similar (normalized Levenshtein; catches the incident's "tomorrow"→"today" edit, leaves "ok"/"ok!" alone) to one submitted to the same conversation within 10 minutes is refused (HTTP 409 / near_duplicate_blocked) naming the prior outbox item — unless force:true. Same-key replays (the documented lost-response retry) bypass the guard and hit idempotent dedup, and SendAgain (an explicit user action) is untouched.
  • list_outbox / cancel_outbox MCP tools (both serve modes): see everything still queued/retrying/uncertain, and stop a send before it crosses the transport boundary. During the incident there was no way to see or stop the overnight-queued message from the MCP surface.

5. MCP ergonomics

wait_for_transmit: true holds the call through auto-retrying states until transport acknowledgment or terminal failure, bounded by wait_seconds (default 25, max 120), so an agent can report truthfully in one call. Interrupted waits re-read the durable state on a detached context and keep the do-not-resend guidance.

Tests

83 new/updated assertions across the stack, including the three called out in the spec:

  • queued-not-transmitted must never report settledTestDaemonQueuedSendNeverReportsSettledOrConfirmed (fake daemon stuck in queued; asserts settled:false, ok:false, transmitted:false, no "delivery confirmed" text) plus TestSendPayloadSettledMatrix pinning settled/transmitted/transport_state for all 8 outbox states.
  • duplicate guard — service-level matrix (blocked → forced; scope: different body / different conversation / short repeats / outside window; same-key replay bypass; similarity threshold table) and end-to-end through the MCP tool.
  • platform mismatchTestSendToConversationPlatformAssertionMismatch, plus send-time enforcement (TestDaemonSendBlockedWhenPlatformCannotSend: hard-down refuses without submitting; queueable outage still queues).
  • TTL: stamping, scheduled-send windows, expiry sweep state scope (uncertain/post-transport rows never touched), lease-race exclusion, 15-hour incident replay (TestExpiredQueuedSendIsCanceledAndNeverDispatched).
  • sendcap tier matrix, /api/status send block, localapi wire round-trips, migration-gate update (staged-store pin now schema 11 + 0011 checksum).

go test ./... green locally (including the R5 migration integration suite); gofmt clean on all touched files.

Notes for review

  • settled semantics changed for uncertain (was true, now false + uncertain:true): an unknown outcome is not a settled one; reporting it settled is what invited "treat as done" during the incident. not_dispatched keeps settled:false/auto_retry:true.
  • Queueable vs hard-down: unconditional send-time blocking on any unavailability would make the durable outbox useless for its core purpose (transient disconnects). The tiering — refuse what won't self-heal, queue-with-TTL what will — is the incident-shaped compromise: the overnight message would have canceled at ~11:10pm instead of double-sending at 2:18pm, and the 404'd WhatsApp path now refuses up front with the real reason.
  • The in-process (standalone daemon) send path now bounds its wait at 25s like the client path did, instead of waiting indefinitely on the request context; both paths share one result builder (internal/tools/send_result.go).
  • Older daemons without the send block are treated as unknown capability (never blocked on), and their missing delivery fields degrade gracefully.

🤖 Generated with Claude Code

After the 2026-08-05 incident — a send reported ok/settled/confirmed sat
~15 hours before transmitting, a manual retry double-texted the
recipient, and WhatsApp sends 404ed while status said connected:

- transport_state queued/transmitted/delivered/uncertain/failed/canceled
  in every durable send result; settled and transmitted are true only on
  transport acknowledgment; results carry the platform actually used and
  the conversation_id written to; "delivery confirmed" wording removed
- per-platform send capability (new internal/sendcap) published at
  /api/status "send", rendered by get_status in both serve modes, read
  by resolve_contact_routes, and enforced at send time: hard-down
  platforms (unpaired, adapter unregistered, auth revoked) refuse
  without queuing; transient disconnects queue with truthful reporting
- outbox send window (migration 0011, expires_at_ms): a send still
  queued when its window closes cancels as expired instead of
  transmitting stale; MCP sends default to 10 minutes (ttl_seconds,
  OPENMESSAGES_SEND_TTL_SECONDS); the lease query excludes expired rows
  so a race can never transmit one; cutover carries windows forward
- near-duplicate guard: a text >= 0.75-similar (normalized Levenshtein)
  to one submitted to the same conversation within 10 minutes is
  refused, naming the prior intent, unless force=true; same-key replays
  keep idempotent dedup; SendAgain untouched
- list_outbox / cancel_outbox MCP tools in both serve modes
- wait_for_transmit + wait_seconds hold the call through auto-retrying
  states until transport acknowledgment
- explanatory 404s (conversation unresolvable in the serving store),
  optional platform assertion on send_to_conversation, and an explicit
  imessage cannot-send refusal

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant