Repository navigation
Conversation
A Signal quote-reply on a v2-primary daemon carried only the quoted message's v2 remote ID to the transport, which resolved the quote from the legacy store. That store lacks every send made through the v2 outbox, and holds phone-sent messages and some incoming ones under other IDs, so those replies failed with "signal reply target not found". The dispatcher now loads the quoted v2 message and fills bridge.MessageRef with its author, occurred time, body and first attachment, following the outbox from a pending request ID to the confirmed message. The Signal adapter passes the ref through, and signallive.QuoteArgs builds the quote from it, keeping the legacy lookup for undescribed refs and legacy IDs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This was referenced Oct 9, 2026
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.
What was wrong
On a v2-primary daemon a Signal quote-reply took this path:
SubmitTextV2→ the outbox message'sReplyToRemoteID(the quoted v2 message'sremote_message_id,internal/messaging/service.go:176) →bridge.TextRequest.ReplyTo.RemoteID→ Signal adapter →signallive.signalQuoteArgs. That last step resolved the quote only from the legacydb.Store(GetMessageByID), because signal-cli needs the quoted message's sent timestamp, author and text for--quote-timestamp/--quote-author/--quote-message. A v2 remote ID is not a legacy ID, so the lookup failed withsignal reply target not found. #224 retries a bare ID as"signal:"+id, which helps only where the legacy row has exactly that ID.What the legacy store holds on v2-primary
I checked rather than assumed.
processReceiveLine(internal/signallive/client.go:2237) first tees each line to v2 (observeIngress) and then still runs the legacy handlers, which writeb.store.internal/app/signal.gobuilds the bridge witha.Store. So legacy keeps receiving Signal on v2-primary. Even so, the legacy lookup cannot find:!v2Primary(cmd/v2stack.go:377), and the adapter expects no echo of its own sends<ts>(signaldecoder.go:386)signal:local:<sha1(conv,"me",ts)>(handleSentMessage→localOutgoingMessageID)signal:<ts>retry misses it tooLive install (GET only,
127.0.0.1:7007)v2_primary: true,v2_send: true. Signal is unpaired (paired: false,connected: false), and its newest message is 2026-08-19 01:16Z, consistent with the ~08-20 unlink.GET /api/v1/outbox?limit=500: 2 pending rows, bothgoogle-primaryanduncertain. One is the 1,959-attempt "no conversation" row Stop Google sends sticking on 'no conversation': detect the account-pairing switch, bound retries, and show refused sends #204 describes. No Signal rows.Status, which v2 reads give a message with no outbox row, so none of them went through the v2 outbox. There is one outgoing reply, a migrated row from 2026-06-22.The fix
The dispatcher loads the quoted v2 message and describes it on the request's
bridge.MessageRef. Signal builds the quote from that description and needs no legacy row.bridge.MessageRefHasAttachmentandAttachmentMIME. The doc comment now states the contract:AuthorIDis empty when the stored message names no sender (always for this account's sends),SentAtis the occurred time, and a zeroSentAtmeans "not described, onlyRemoteIDis meaningful".messaging.replyRefForLease(new,reply_ref.go)occurred_at_ms, body, first attachment), or a bare ref when the store does not hold the message or it is an outgoing one still waiting for its transport ID. It runs beforeMarkTransportCalled, so a store error fails the attempt asnot_dispatched(load_reply_target*) without calling the transport. Text and media both use it.messaging.targetRefForLeasemessageAuthorID); reaction and read refs are unchanged.sqlite.MessageRepository.FirstAttachmentMIME(new)message_attachmentsrow, else the attachment of the message's newest outbox intent (outbox-sent media keeps its attachment only there).ReplyTarget(*bridge.MessageRef)hands every ref field to signallive.SendTextRequest/SendMediaRequesttake asignallive.ReplyTargetinstead of a reply ID.signallive.QuoteArgs(new, pure,reply_quote.go)signalQuoteArgsnow builds its arguments through it, from the legacy row as it always read it.Bridge.replyQuoteArgssignal:prefix) whose legacy row exists: of the v2 writers only the legacy-primary mirror keys messages that way, and its copy has no sender or attachment.docs/agent-runbook.mdsignal reply target not foundfailure.Quote timestamp
Signal quotes by the quoted message's sent timestamp. For an incoming message the v2 remote ID is a SHA-1 (
v2keys.SignalIncomingSourceID), so the timestamp comes fromoccurred_at_ms. I confirmed that equals the timestamp the decoder used, which isdataMessage.timestamp(orsentMessage.timestamp), else the envelope's (signaldecoder.go:274-297, 371-391). The migration also sets it to the legacyTimestampMS(migration/transform.go:789).One case differs. Confirming an outbox send repoints
remote_message_idto signal-cli's timestamp but leavesoccurred_at_msat the submit time (outbox.gorepointLocalMessage). So for a message this account wrote, a remote ID that is a positive decimal wins overSentAt. I limited that rule to self-authored targets because incoming Signal IDs have been SHA-1 sincesignalIncomingSourceIDwas introduced. A numeric ID on an incoming row exists only in synthetic data (the R5 fixture has one), and with the limit those quote exactly what legacy did.Invariants (tested)
Dispatcher (
TestReplyRefInvariants: 40 seeds of random conversations mixing incoming messages with and without a sender, 0–2 attachments, padded and empty bodies, and outgoing text and media sends that were confirmed or never sent; each reply is dispatched twice by forcing one not-dispatched retry):SentAtis zero exactly when the message is not held or is an outgoing message whose remote ID is still its own outbox request ID.AuthorIDis the sender identity's canonical value ("" for none),SentAtisoccurred_at_ms,Textis the body, and the attachment fields are the first attachment's.not_dispatchedwith no transport call (TestReplyTargetLoadFailureFailsBeforeTransport).Quote builder (
TestQuoteArgsInvariants: 5,000 seeded random targets, against an independently written oracle):--quote-timestamp <positive decimal> --quote-author <non-empty> --quote-message <non-empty>.RemoteIDis a positive decimal uses it; every other target usesSentAt. It fails, with "timestamp is unavailable", exactly when the chosen source is not positive.AuthorIDor the account quotes the account; any other author goes through the contact resolver.Attachment.Legacy compatibility:
signalQuoteArgsequals a verbatim copy of the pre-change implementation (TestLegacyQuoteLookupMatchesPreChangeImplementation). One intended difference: a negative stored timestamp now fails as unavailable instead of being passed to signal-cli. The legacySendText/SendMediapaths, and undescribed refs, still use that lookup.TestV2DescribedQuoteMatchesLegacyLookup, external test). There are 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, and sync-sent attachment. Envelope and data timestamps sometimes disagree and the data timestamp is sometimes missing. Each line goes through both retained paths: the legacy receive handler, and the durable tee, v2 decoder and ingest worker. Every resulting pair (127 messages, every shape covered) is compared. The legacy side issignalQuoteArgson the legacy row. The new side is a realMessageServicereply dispatched to a scripted Signal account, with the capturedMessageRefsent through the real adapter conversion into the quote builder against an empty legacy store. The arguments are identical, and every ref is described.occurred_at_msis the sent timestamp of its line.signal:ID the legacy store holds quotes from its row (TestReplyQuoteArgsQuotesALegacyIDFromItsLegacyRow).Tests
internal/messaging/reply_ref_test.gocovers six cases: a reply to an incoming message (text and media), to this account's confirmed send (transport ID, empty author), to a send pending at submit and confirmed before dispatch, to a canceled send (bare), to an unheld remote ID (bare), and to outbox media with no caption (MIME fromoutbox_attachments). It also holds the R1–R5 property test and the pre-call failure test.internal/storage/sqlite/reply_target_test.go: ordinal order, outbox fallback, stored row over outbox, none.internal/signallive/reply_quote_test.go: a rule table, the Q1–Q5 property test, the L1 differential, description versus legacy fallback, the L4 legacy ID, andSendTextRequest/SendMediaRequestsignal-cli argv for described targets the legacy store lacks.internal/signallive/reply_quote_differential_external_test.go: L2 and L3.internal/bridgeadapters/signal/reply_target_test.go: a reflection check that everyMessageReffield reachessignallive.ReplyTargetunder the same name, and thatSendTextandSendMediahand the described ref to the poller.cmd/r5_signal_quote_reply_test.goruns end to end on the real migrated R5 store with a v2-primary stack and a scripted Signal adapter:POST /api/v1/outbox/messagesto this account's own v2 outbox send. The inert legacy store gets no row, and the quote carries signal-cli's timestamp, not the submit time.SubmitMediaV2) to a live-ingested incoming message (SHA-1 ID, ACI author, the decoder's timestamp).signal:1700000001000and the migrated ownsignal:local:abc123r5, each quoting exactly what its legacy row gives.Mutation check
Each mutant was applied alone and then reverted:
SentAtIsFromMecreated_at_msforSentAtVerification
GOWORK=off go vet ./...is clean, and gofmt is clean on every touched file.GOWORK=off go test -count=1 ./...passes all 34 packages at the PR head (e0ad2851).Interplay with open PRs
signalQuoteArgs(signalReplyTarget). This PR changes only the lines after its timestamp check, so the two should merge with at most a trivial conflict, and Adopt migrated v2 conversations in the legacy mirror instead of refusing them #224's retry becomes the undescribed-ref fallback. After Adopt migrated v2 conversations in the legacy mirror instead of refusing them #224 the legacy-primary mirror keys Signal targets by the bare timestamp, so such a reply resolves to the real v2 message and quotes the transport timestamp rather than the projector's confirm time, which is more accurate.TextRequest/MediaRequestliterals next to theReplyToline, a trivial conflict. Its retry budget also bounds a reply whose target cannot be resolved (today such a row retries every 5 s).Residuals
sendReaction -t, the same class of bug. It is filed as a separate task, which can reuseQuoteArgs's timestamp rule.SenderNumberwas empty) quotes as this account, which is what the legacy lookup did for such rows.store_failedhas not yet been repointed to its transport ID, so it gets a bare ref untilRepairStoreFailedruns.🤖 Generated with Claude Code