Skip to content

Route v2-primary reactions through the outbox - #227

Open
MaxGhenis wants to merge 2 commits into
claude/signal-v2-reaction-timestampfrom
claude/v2-react-outbox
Open

MaxGhenis wants to merge 2 commits into
claude/signal-v2-reaction-timestampfrom
claude/v2-react-outbox

Conversation

@MaxGhenis

@MaxGhenis MaxGhenis commented Oct 10, 2026 •

Copy link
Copy Markdown
Owner

Stacked on #226 (base claude/signal-v2-reaction-timestamp, head 6b3b8750), which is stacked on #225. #226 fixes the Signal transport half: before it, an outbox reaction to an incoming Signal message passed a SHA-1 to signal-cli sendReaction -t and ended uncertain. Routing reactions through the outbox without it would hit that. Merge #225, then #226, then this. This PR's change is the single commit on top.

What was wrong (reproduced)

On a v2-primary daemon the read API hands out v2 conversation and message IDs (32-hex keys; runbook "v2 cutover: conversation IDs re-key"), and the web UI posts exactly those to POST /api/react. That handler had no V2Primary branch. It told platforms apart by a signal:/whatsapp: prefix or a lookup in the legacy store, and anything else fell to Google Messages. So every reaction to a v2 message, on any platform, went down the Google path with a v2 message ID.

cmd/r5_react_v2_primary_test.go runs the daemon's handler wiring over a migrated v2-primary store with scripted adapters and posts what the UI posts, for a Signal, a WhatsApp and a Google conversation. On unchanged main (19e35d97) and on #226's head:

POST /api/react {"conversation_id":"875aee21…","message_id":"2bd005de…",…} -> 503 {"error":"not connected to Google Messages"}   (Google)
POST /api/react {"conversation_id":"3325e174…","message_id":"853a88f5…",…} -> 503 {"error":"not connected to Google Messages"}   (WhatsApp)
POST /api/react {"conversation_id":"e5b8b7c3…","message_id":"12ac7b03…",…} -> 503 {"error":"not connected to Google Messages"}   (Signal)
the <platform> transport received 0 reaction requests, want 1

With a Google client connected, the request instead asks Google to react to an ID that is not a Google message ID. The react_to_message MCP tool followed /api/react in client mode, and in-process it called the legacy senders, which look the target up in the legacy messages.db by message ID. Meanwhile MessageService.SendReaction, the durable path, had no production caller. The live install reports v2_primary: true (GET /api/status, 2026-10-10 01:46Z), so this is live there.

The fix

  • v2wire.SubmitReactionV2 resolves the target by its v2 message ID. The target decides the account and the conversation. A supplied conversation_id must name that conversation, by v2 ID or by its remote ID (signal:+1…, a WhatsApp JID, a Google thread id: the keys pre-cutover callers hold, issue v2-primary cutover makes Signal 1:1 threads unaddressable: legacy conversation IDs stop resolving, direct conversations project without participants/title #155). An unknown message, or one outside the named conversation, is refused (422 reaction_target_unavailable) before anything is queued.
  • POST /api/v1/outbox/reactions (v2-primary only, idempotency_key required) queues a reaction like /api/v1/outbox/messages, and returns the submission at once. A legacy-primary daemon answers 409 legacy primary: use /api/react.
  • POST /api/react delegates to the outbox on v2-primary. Legacy-primary daemons, including ones with OPENMESSAGES_V2_SEND=1 (whose read API still hands out legacy IDs), keep the legacy senders unchanged.
  • The dispatcher confirms a reaction and records it as this account's own (reactor_key = 'self', which reads show as "me"/"You") in one transaction: OutboxRepository.ConfirmReaction. The adapters store nothing when they send a reaction (the legacy senders updated the stored reactions themselves after the send), so without this the reaction would appear only if a transport later reported it. The own-reaction row uses the transport's AcceptedAt (or the dispatcher clock), and goes through the same ApplyReaction ordering an ingest report uses, so a later report for that reactor replaces it. A reaction that ends uncertain, rejected, not_dispatched or canceled writes nothing. The ingest worker and the migration now use the same SelfReactorKey/SelfReactorLabel constants.
  • MCP. react_to_message queues on the outbox in-process on v2-primary (waiting at most 25 s) and, in transportless client mode, when the app's /api/status reports v2_primary; otherwise it keeps /api/react. On v2 it accepts idempotency_key and returns outbox_id, state, settled and the key. A reaction that has been queued is never reported as an error, because an error invites a second reaction. localapi gains SubmitReaction, DaemonStatus.ReactionsViaOutbox, and a typed React result.
  • Web UI. A 202 shows "Reaction not sent yet. The app keeps trying; it's in the outbox, where you can cancel it." The outbox tray lists reaction rows while they are queued, dispatching or not_dispatched, with Cancel. It hides them once they settle, because a reaction has no "send again" and an action on it would fail.

What the HTTP answer means now

The legacy handler answered only after the transport call. Delivery is now the dispatcher's job, so on v2-primary /api/react queues the reaction, waits up to 8 s (under localapi's 10 s client timeout), and says how far it got:

Status Body Outbox state
200 success: true confirmed, store_failed: the platform accepted it
202 success: true, queued: true queued, dispatching, not_dispatched: stored, still the app's to send; do not repeat
502 success: false, error uncertain (may have been applied), rejected
409 success: false, error canceled
422 / 501 / 400 error nothing queued: unknown target / account cannot react / bad request

Every 2xx/409/502 answer carries outbox_id and state. A /api/react request without idempotency_key mints one, so a repeated POST queues a second reaction (as before, where each POST called the transport). The UI keeps using /api/react and treats a non-2xx as failure, as before.

Invariants (stated, and executed as tests)

Invariant Test
Routing. However a reaction is addressed, it is queued iff the conversation key is empty or names the target's own conversation (v2 ID or remote ID, whitespace aside), and a queued reaction always sits under the target's account and conversation. A refused one queues nothing. TestSubmitReactionV2QueuesOnlyInTheTargetsConversation (property, 300 cases; acceptable keys written out per conversation, not derived with the code under test)
Atomicity. Confirming a reaction and writing its own-reaction row are one transaction. A lost lease, a transport never called, a non-reaction row, a zero time or a failing read-model write leaves the row dispatching and the read model untouched. TestConfirmReactionIsAllOrNothing
Differential: outbox vs echo. For any history mixing this account's delivered reactions, undelivered ones (rejected / uncertain / retrying), reports of its own reactions from other devices and other people's reactions, with tied and out-of-order times, a store where the outbox confirmed the own reactions holds exactly the rows of one where the transport reported each of them. TestConfirmedOwnReactionIsStoredAsItsEchoWouldBe (property, 150 cases)
What a reader sees. Through the real service and dispatcher, for any history applied in any order: each reactor shows at most one reaction; it is the latest of that reactor's delivered reactions by the reaction's own time, and a latest remove shows nothing; an undelivered reaction changes nothing; each submitted reaction reaches the transport exactly once and ends in the state its answer calls for. TestReactionsAReaderSeesAreTheLatestDeliveredOnes (property, 150 cases)
HTTP contract is total. For every outbox state and an unknown future one: the status is one of 200/202/409/502 and names the intent; success iff 2xx; 200 iff accepted; queued iff 202; error iff the intent will not deliver. TestReactOutcomeAnswersEveryState
Legacy-primary unchanged. With v2 send on but legacy primary, /api/react calls the legacy sender, answers {success:true}, and writes no outbox row. TestReactStaysOnLegacySendersUntilV2IsPrimary, existing TestReactUses*
MCP never invites a duplicate. Once a reaction is queued (settled or not, wait interrupted, answer lost mid-flight) the tool result is not IsError. TestV2ReactToMessage*, TestDaemonReactToMessageOnAV2PrimaryApp

A mutation check: stamping the own-reaction row with the confirm-time clock instead of the transport's accepted time fails four of these tests (including both properties), and they pass again once it is reverted.

End to end: TestR5ReactRoutesThroughTheOutboxOnV2Primary (the reproduction above) now routes all three platforms to their own transport with the right conversation and target remote IDs, and the reloaded thread shows the 👍 as "me" with no echo. Playwright: four new specs (tray visibility per state; delivered reaction shows a "You" pill; a disconnected platform gives 202, the queued notice and a tray row that cancels; a refused reaction gives 502 and its message), using a scriptable reaction sender added to cmd/e2e-server.

Verification

Targeted, per the host's disk hold: go vet and go test on internal/{web,tools,localapi,v2wire,messaging,storage/sqlite,ingest,migration,bridgeadapters/...}, cmd/e2e-server, go test ./cmd/ -run 'R5|React|MCP|Serve', and -race on the new reaction tests. Playwright: the 17 reaction / outbox / send-queue specs. CI runs the full suite.

Not in this PR

  • A Signal reaction whose target can never be named (Name a Signal reaction's target by author and sent timestamp on the v2 outbox path #226: migrated local:<sha1> own messages and the like) stays not_dispatched and retries every 5 s with no cap. It is now visible in the tray and cancelable. Stop Google sends sticking on 'no conversation': detect the account-pairing switch, bound retries, and show refused sends #204 proposes the retry budget that would bound it.
  • On Google Messages the phone's copy is authoritative. Every Google frame for a message applies its full reaction snapshot (ReplaceEmbeddedReactions), which removes active reactors the snapshot omits, and the Google decoder names reactors by participant ID, never as self. So the next frame for the message replaces the "self" row with the reactor the phone names, or removes it if the phone has not applied the reaction yet: a reaction can briefly disappear and come back. It never shows twice. Documented in the runbook; left as is because the phone is the source of truth for Google reactions.
  • A migrated message whose legacy reaction named nobody keeps it as an anonymous reactor (anon:👍). Reacting 👍 again adds "me" beside it, so the pill reads 2 with only "You" listed (seen in the Google fixture; documented in the runbook).

Docs: the runbook gains "Reactions on v2-primary go through the outbox" (routes, the status table, what the user sees, how to find and cancel stuck reactions) and corrects the MCP-serving line that said reactions went to /api/v1/outbox. CLAUDE.md lists both routes.

🤖 Generated with Claude Code

MaxGhenis and others added 2 commits October 9, 2026 21:45
On a v2-primary daemon the read API hands out v2 conversation and message
IDs, but POST /api/react had no v2-primary branch: it told platforms apart by
a signal:/whatsapp: prefix or a legacy-store lookup and fell back to Google
Messages. Every reaction to a v2 message, on any platform, went down the
Google path with a v2 message ID (503 "not connected to Google Messages" with
no Google client). The react_to_message MCP tool followed it in client mode
and used the legacy senders in-process. MessageService.SendReaction, the
durable path, had no production caller.

- v2wire.SubmitReactionV2 resolves the target by v2 message ID; the target
  decides account and conversation, and a supplied conversation key must name
  that conversation (v2 ID or remote ID).
- POST /api/v1/outbox/reactions (v2-primary only, idempotency key required)
  queues a reaction like the other outbox submissions. POST /api/react
  delegates to the outbox on v2-primary and waits up to 8 s: 200 delivered,
  202 queued and still retrying, 502 uncertain or rejected, 409 canceled,
  422 unknown target. Legacy-primary daemons are unchanged.
- The dispatcher confirms a reaction and records it as this account's own
  (reactor_key "self") in one transaction (OutboxRepository.ConfirmReaction),
  so the UI shows it without an echo; undelivered reactions write nothing.
- react_to_message uses the outbox in-process on v2-primary and, in client
  mode, when the app reports v2_primary; localapi gains SubmitReaction and a
  typed React result.
- The web UI reports a queued reaction and lists it in the outbox tray while
  it is queued, dispatching or retrying.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

This branch has not been deployed

No deployments
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