Repository navigation
Conversation
MaxGhenis
force-pushed
the
claude/signal-v2-reaction-timestamp
branch
from
October 10, 2026 00:37
cb22f41 to
52a953d
Compare
…2 outbox path A Signal reaction dispatched through the durable outbox ran `signal-cli sendReaction -a <author> -t <v2 remote ID>`. signal-cli's -t is the target message's sent timestamp. The v2 remote ID is that timestamp only for this account's own sends: a decoded incoming message's is a SHA-1 (v2keys.SignalIncomingSourceID). signal-cli rejects that while parsing its arguments, and the outbox row ends uncertain with nothing sent. A reaction carries nothing but the name of its target, so a wrong author or timestamp is still a well-formed command, aimed at a message that does not exist. signallive.ReactionTargetArgs sends one only when the store vouches for both, and SendReactionRequest fails before signal-cli runs otherwise: - An incoming message is named by its stored sender and occurred time, and only when its remote ID is a SHA-1, the ID a Signal receiver gives a message it keyed by sender and sent timestamp (the v2 decoder, the legacy receiver, a Signal Desktop import of a row with a sent time). One with no stored sender, or under any other ID, is refused. - A message this account sent is named by this account and its remote ID, and only when that ID is a decimal timestamp (an outbox confirmation, a sync message from the phone, the legacy SendMedia and legacy-primary projector). Its occurred time is not trusted: an outbox send still on its request ID carries its submit time, a migrated scheduled send its creation time, and a migrated "local:" row may carry the wall clock the legacy SendText read after signal-cli returned. bridge.MessageRef gains Outgoing, set by the dispatcher from the stored direction, because an empty AuthorID does not mean this account: an incoming message can lack a sender too. The Signal Desktop importer stored a row with no sent time under a plain SHA-1 of the time it was received, which passes for a message keyed by its sent timestamp. It now marks that row's source ID (v2keys.SignalReceivedSourceID, "received:<sha1>"), keeping the message ID so a re-import rewrites a row stored earlier. A v2 store migrated before this can still hold such a row unmarked. A refused reaction is not dispatched and retries, so one aimed at a pending own send goes out once the send is confirmed. Quotes keep their rule (QuoteArgs is unchanged). The legacy SendReaction path is unchanged, and no surface submits a v2 reaction yet: /api/react and react_to_message still call it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MaxGhenis
force-pushed
the
claude/signal-v2-reaction-timestamp
branch
from
October 10, 2026 01:26
52a953d to
6b3b875
Compare
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Stacked on #225 (
claude/signal-v2-quote-replies, heade0ad2851), which this builds on. Merge that first; this PR's own change is the single commit on top.What was wrong
A Signal reaction dispatched through the durable outbox took this path:
dispatchReactionLease→targetRefForLease(internal/messaging/dispatch.go) →bridge.ReactionRequest.Target→ Signal adapterSendReaction→signallive.Bridge.SendReactionRequest, which ransignal-cli's
-tis the target message's sent timestamp. The v2 remote ID is that timestamp only for this account's own sends. An incoming message's is a SHA-1 (v2keys.SignalIncomingSourceID,signaldecoder.go), and a migrated own message can carrylocal:<sha1>.Confirmed before changing anything
Two tests written against the unchanged code (
e0ad2851), both failing:The first calls
SendReactionRequestwith therunSignalCLIstub. The second ingests a real receive line through the v2 decoder and worker, submits a reaction toMessageService, and runsDispatchDuethrough the real Signal adapter and bridge. It is kept in this PR and now passes. The first is replaced by the rule and argv tables below, because the function's signature changed.What signal-cli does with that argument, run against an empty config directory (signal-cli 0.14.8):
I then ran the old code with a stub that fails that way. The outbox row ended
uncertain(class=transient code=send_reaction): the failure is aCommandErrorwithout the not-dispatched marker, so the adapter leavesDispatchunset andrecordSendErrorfalls through to uncertain. Nothing is sent and nothing retries.How far this reaches today
Nothing in production submits a reaction to the v2 outbox yet, so this is a latent bug, not a live one.
MessageService.SendReactionhas no caller outside tests. The web UI's/api/reactand thereact_to_messageMCP tool both callApp.SendSignalReaction→ the legacysignallive.Bridge.SendReaction, which looks the target up in the legacy store by message ID. That path is untouched here. This fix matters when reactions are routed through the outbox.The fix
A reaction carries nothing but the name of its target: author and sent timestamp. If either is wrong, signal-cli still gets a well-formed command, aimed at a message that does not exist. So the transport now sends a reaction only when the store vouches for both, and stops before signal-cli otherwise.
-a-toccurred_at_ms. A SHA-1 is the ID a Signal receiver gives a message it keyed by sender and sent timestamp, and each writer that uses it stores that timestamp as the occurred time: the v2 decoder, the legacy receiver, a Signal Desktop import of a row with a sent time. The ID itself is not sent.signal reaction target timestamp is unavailablesignal reaction target author is unavailableSendMedia, the legacy-primary projector.signal reaction target timestamp is unavailablebridge.MessageRefOutgoing: this account sent the message. An emptyAuthorIDcannot say that, because incoming messages can lack a sender too. The doc comment says so.messaging.targetRefForLease,replyRefForLeaseOutgoingfrom the stored direction. Nothing else changes in the dispatcher.signallive.ReactionTargetArgs(new, pure,reaction_target.go)Bridge.SendReactionRequestsignallive.ReactionTarget{RemoteID, AuthorID, Outgoing, SentAt}instead of two strings, and fails before running signal-cli on a target it cannot name.ReactionTarget(bridge.MessageRef)hands the dispatcher's description to signallive.ReplyTargetcarriesOutgoingtoo (#225's reflection test requires everyMessageReffield);QuoteArgsdoes not read it, and quote behaviour is unchanged.v2keysIsSignalIncomingSourceID(40 lowercase hex digits) andSignalReceivedSourceID(received:<sha1>).received:<sha1>instead of a plain SHA-1 of that time. Its message ID is unchanged, so a re-import rewrites the row an earlier import stored instead of adding one. The migration carries the source ID into v2 as the remote ID.docs/agent-runbook.mdA refused reaction is a plain error, not a
CommandError, so the adapter reports it as not dispatched and the outbox retries it. A reaction to this account's own pending send therefore goes out once the send is confirmed and its remote ID becomes Signal's timestamp.This is not the rule you asked for, and why
You asked for #225's quote rule:
occurred_at_ms, except a decimal remote ID for a self-authored target. My first version did that (plus a dispatcher guard for unsent sends). Two rounds of independent review (GPT-6.1 Sol; it could read but not run tests) each requested changes, and each time the reviewer was right.Round 1. Three stored shapes for which that rule sends a well-formed, wrong target. I reproduced all of them against that version before changing it. Each ran this, for a message with no such identity:
sendingis imported with a derived request ID, its creation time, and no outbox row (migration/transform.go). My guard looked for the outbox row, found none, and the creation time went out as-t.AuthorID, which was read as this account. The legacy receiver stored a group message with no source that way (handleDataMessagereturns early only when both the source and the group are empty), migration keeps it senderless, and the legacy-primary mirror records no sender at all. My "I know of no Signal row like that" in the first description was wrong.local:<sha1>row's time is not always Signal's. The legacyBridge.SendTextsetstimestamp := now().UnixMilli()after signal-cli returns and derives the alias from it. The phone's own sends were stored under the same alias with the right time, and nothing in v2 tells the two apart.The premise of the fallback, that an own message's occurred time is its Signal timestamp, fails for all three. So for a message this account sent, only a decimal remote ID is trusted. The dispatcher guard is gone: the transport rule does not need it, and it missed the first shape.
Round 2. The round-1 findings were confirmed resolved, with one new one: the same kind of hole on the incoming side, which I had listed as a known gap instead of fixing.
signalDesktopMessageTimestampfalls back toreceived_atwhen a Signal Desktop row has nosent_at, and the importer keyed that row by a plain SHA-1 of the received time: indistinguishable from a message keyed by its sent timestamp, so a reaction would name the received time. I reproduced it: with the importer's marker in place but the rule still trusting any incoming ID (mutant N15), the new importer-to-transport test reportsThe fix is at the writer and in the rule: the importer marks such a row's source ID, and an incoming target is named only under a receiver's SHA-1.
Quotes keep #225's more lenient rule. A quote carries the quoted text; a reaction carries nothing else. The same shapes reach
QuoteArgs, which I have not changed; I've noted them on #225.Invariants (tested)
Transport (
TestReactionTargetArgsInvariants: 5,000 seeded random targets against an independently written oracle; the generator must produce at least 150 own and 150 incoming successes, 200 author failures and 500 timestamp failures)-a <non-empty> -t <positive decimal>.SentAtholds.AuthorIDand the timestamp isSentAt, and only when the remote ID is 40 lowercase hex digits. NoAuthorIDfails as author unavailable; any other remote ID, or a zero or non-positiveSentAt, fails as timestamp unavailable.-tis never a remote ID other than a decimal one of an own message, never the stored time of a message whose ID does not vouch for it, and-ais never the account for an incoming message that names no sender.QuoteArgsnames the same author and timestamp for it (one shape excluded and labelled: an incoming message stored under this account's own address, whichQuoteArgsreads as an own message).Dispatcher (
TestReactionTargetRefInvariants: 40 seeds of random conversations mixing incoming messages with and without a sender and with SHA-1 or decimal IDs, and outgoing text and media sends that were confirmed, canceled, or still scheduled; each reaction is mapped twice by forcing one not-dispatched retry)Outgoingis set exactly for a message this account sent, whether or not the message names a sender.AuthorIDis the sender's canonical value ("" for none) andSentAtthe occurred time.Writer to transport
TestSignalDesktopRowWithoutASentTimeCannotBeAReactionTarget, in the importer package so it runs the real importer with a stubbed export). Two incoming rows go throughImportFromDB,migration.Transform, a realMessageServicereaction and the adapter conversion intoReactionTargetArgs. The row with a sent time is named-a <peer> -t <sent time>. The row without one reaches the transport as the peer's incoming message at the received time underreceived:<sha1>, and is refused.TestSignalDesktopReimportMarksARowStoredBeforeTheMarker): a row an earlier import stored under the unmarked hash ends as the same single row with the marked source ID.SignalReceivedSourceIDnever satisfiesIsSignalIncomingSourceID(v2keystests).End to end
TestV2ReactionMatchesLegacyReaction). It reuses Quote Signal replies from the v2 message on v2-primary #225's corpus: 12 seeds of random signal-cli receive lines in eight shapes (incoming from E.164, from a known ACI, from an unknown ACI, in a group, attachment-only, text plus attachment, sync-sent text, sync-sent attachment), each fed through both the legacy receive handler and the v2 decoder and worker. For every message both stores hold (127, every shape covered) it sends the same random reaction (emoji × add/remove/switch) down both paths and compares the signal-cli argv: the legacySendReactionon the legacy row, and a reaction submitted toMessageServiceand dispatched through the real Signal adapter to a bridge whose legacy store is empty. The argv are identical. This cannot detect a stored timestamp that both paths share and that is wrong, which is what both reviews found.TestV2ReactionRefusesATargetItCannotName): seven stored shapes, each imported into a v2 store and reacted to through the real adapter and bridge: migratedsending; migratedlocal:alias; incoming with no sender under a SHA-1, a decimal ID, and the mirror'ssignal:<decimal>; a peer's Desktop row underreceived:<sha1>; a peer's message under a decimal ID. Zero signal-cli calls, the rownot_dispatched/transient, and the recordederror_detailnames the right reason.TestV2ReactionToOwnPendingSendWaitsForItsTransportTimestamp): zero calls while the send is scheduled; after it is confirmed, exactly onesendReaction, with-tequal to the timestamp signal-cli returned for the send.Bridge.SendReactionis not in the diff, and its characterization test still passes.reply_quote.godiffers from Quote Signal replies from the v2 message on v2-primary #225 by one struct field and its doc comment; Quote Signal replies from the v2 message on v2-primary #225's quote tests pass unmodified apart from one fixture line.Tests
internal/signallive/reaction_target_test.go: a 22-row rule table; the A1–A6 property test; signal-cli argv through therunSignalCLIstub for each kind of target, checking that the five refused kinds run no command and return a plain error.internal/signallive/reaction_target_external_test.go: the dispatcher-level reproduction, E1, E2 and E3.internal/importer/signal_desktop_reaction_test.go: W1 and W2.internal/v2keys/derive_test.go: W3 and the recogniser's table.internal/bridgeadapters/signal/reaction_target_test.go: a reflection check that everyReactionTargetfield is theMessageReffield of the same name and that the set isRemoteID, AuthorID, Outgoing, SentAt;SendReactionhands the description to the poller; the real refusal errors are classified transient and not dispatched, with no lifecycle transition.internal/messaging/reaction_ref_test.go(throughMessageService.DispatchDuewith the scripted registry): a senderless incoming target and an own target differ only inOutgoing; a reaction to an own scheduled send carries its request ID, then the transport's ID after the send confirms; described reply refs carryOutgoing; D1–D5.cmd/r5_signal_reaction_test.go, on Quote Signal replies from the v2 message on v2-primary #225's migrated v2-primary harness with a reaction-capable scripted Signal account: a reaction to a live-ingested incoming message (SHA-1 remote ID → the decoder's timestamp and the ACI author); to this account's own outbox send (signal-cli's timestamp, not the stored submit time); and three refusals on the real migration output: the fixture's incoming row under a decimal ID, itslocal:abc123r5, and its scheduled send caught insending.SendReactionRequesttests inclient_test.goand the adapter's mapping test now pass described targets. One line of Quote Signal replies from the v2 message on v2-primary #225'sreply_target_test.gofixture sets the new field.Run locally, with a private build cache:
go build ./...,go vet ./..., and the fullsignallive,bridgeadapters/...,messaging,bridge,importer,migrationandv2keyspackages plus theTestR5*tests incmdall pass; the new tests also pass under-race. I did not rungo test ./...locally (the host was short on disk); CI does.Mutation check
Each mutant was applied alone and the targeted tests rerun.
All eighteen were caught.
-tis its remote ID (the original bug)OutgoingOutgoingOutgoingSentAtSendReactionRequestignores the refusalOutgoingOutgoingAuthorIDBehaviour changes to be aware of
local:<sha1>row: what the legacySendTextsent, and what was sent from the phone, before the v2 cutover. Some carry Signal's timestamp (the phone's) and some the wall clock (SendText's), and v2 cannot tell which. Refusing all of them is the cost of never sending a reaction that silently names nothing. Own messages stored under signal-cli's timestamp still work (legacySendMedia, the legacy-primary projector, and everything sent or synced since cutover). On this path those reactions failed before this PR too (-t local:…). If you would rather send them and accept that some are lost, it is one branch inReactionTargetArgs; mutant N2 is exactly that change.received:<sha1>), and a re-import rewrites the source ID of such a row stored earlier. Its message ID, timestamp and everything else are unchanged.SendReactionRequestno longer reads an empty author as this account. The dispatcher saysOutgoingfor every own message, and the adapter is the function's only caller.not_dispatchedand retries every 5 s (defaultRetryDelay), which has no cap on this branch. The open Stop Google sends sticking on 'no conversation': detect the account-pairing switch, bound retries, and show refused sends #204 proposes a general retry budget for such rows; this PR adds none of its own.Not covered
sent_atempty for a message row; the exporter and importer are written as if it can.missing-edit:<sha1>) is refused, because its ID is not a receiver's SHA-1. Round 2's reviewer traced its stored time to the original's sent timestamp; I did not widen the rule for it.text,story-reply, empty) when they have a body, and the importer treats anything not typedoutgoingas incoming. If Signal Desktop stores a message this account wrote under such a type, it is imported as the peer's. The reviewer raised this and could not establish such a row; neither can I. Unchanged here.AuthorIDas this account (whatsapplive.SendReactionRequest).Outgoingis there for it to use; I did not change it or check whether WhatsApp has senderless incoming rows./api/reacthas noV2Primarybranch and routes by asignal:/whatsapp:prefix or a legacy-store lookup, while the UI on v2-primary sends v2 conversation and message IDs. That is a separate change (route reactions through the outbox), flagged as a follow-up task; it is also what would make this fix reachable.🤖 Generated with Claude Code