Skip to content

Quote Signal replies from the v2 message on v2-primary - #225

Open
MaxGhenis wants to merge 1 commit into
mainfrom
claude/signal-v2-quote-replies
Open

MaxGhenis wants to merge 1 commit into
mainfrom
claude/signal-v2-quote-replies

Conversation

@MaxGhenis

@MaxGhenis MaxGhenis commented Oct 9, 2026 •

Copy link
Copy Markdown
Owner

What was wrong

On a v2-primary daemon a Signal quote-reply took this path: SubmitTextV2 → the outbox message's ReplyToRemoteID (the quoted v2 message's remote_message_id, internal/messaging/service.go:176) → bridge.TextRequest.ReplyTo.RemoteID → Signal adapter → signallive.signalQuoteArgs. That last step resolved the quote only from the legacy db.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 with signal 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 write b.store. internal/app/signal.go builds the bridge with a.Store. So legacy keeps receiving Signal on v2-primary. Even so, the legacy lookup cannot find:

Quoted message v2 remote ID Legacy row Why the lookup misses
This account's send through the v2 outbox (text or media) Signal's timestamp, set at confirm none The legacy projector runs only when !v2Primary (cmd/v2stack.go:377), and the adapter expects no echo of its own sends
This account's send from the phone after cutover (sync transcript) <ts> (signaldecoder.go:386) signal:local:<sha1(conv,"me",ts)> (handleSentMessage → localOutgoingMessageID) Different ID. #224's signal:<ts> retry misses it too
Incoming message from a sender the capture-time contact cache did not resolve but the legacy handler did (it refreshes contacts on a miss) SHA-1 over the raw ACI SHA-1 over the resolved number, in another conversation Different ID
Anything received while the legacy handler failed or quarantined the line as decoded none Not written

Live 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, both google-primary and uncertain. 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.
  • I read 58 Signal threads (up to 200 messages each, 2,837 messages). All 458 outgoing messages have an empty 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.
  • So no Signal reply is stuck or failed on the live install, because Signal has had no link to send on since ~08-19. This fix matters once it is re-linked.

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.

Where Change
bridge.MessageRef Adds HasAttachment and AttachmentMIME. The doc comment now states the contract: AuthorID is empty when the stored message names no sender (always for this account's sends), SentAt is the occurred time, and a zero SentAt means "not described, only RemoteID is meaningful".
messaging.replyRefForLease (new, reply_ref.go) Resolves the quoted message in the item's conversation: by remote ID, else through the outbox row whose transport request ID it is. That second step covers a reply submitted while its quoted send was pending, after the confirm moved the message to the transport's ID. It returns a described ref (current remote ID, author canonical value, 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 before MarkTransportCalled, so a store error fails the attempt as not_dispatched (load_reply_target*) without calling the transport. Text and media both use it.
messaging.targetRefForLease Shares the author lookup (messageAuthorID); reaction and read refs are unchanged.
sqlite.MessageRepository.FirstAttachmentMIME (new) The lowest-ordinal message_attachments row, else the attachment of the message's newest outbox intent (outbox-sent media keeps its attachment only there).
Signal adapter ReplyTarget(*bridge.MessageRef) hands every ref field to signallive. SendTextRequest/SendMediaRequest take a signallive.ReplyTarget instead of a reply ID.
signallive.QuoteArgs (new, pure, reply_quote.go) The author, timestamp and text rules (below). The legacy signalQuoteArgs now builds its arguments through it, from the legacy row as it always read it.
Bridge.replyQuoteArgs A described ref quotes from the description. An undescribed ref still goes to the legacy lookup (existing queued rows, unheld targets). So does a legacy message ID (signal: 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.md New "Signal quote-replies on v2-primary" section: the mechanism, what legacy cannot find, the timestamp rule, and how to read a signal reply target not found failure.

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 from occurred_at_ms. I confirmed that equals the timestamp the decoder used, which is dataMessage.timestamp (or sentMessage.timestamp), else the envelope's (signaldecoder.go:274-297, 371-391). The migration also sets it to the legacy TimestampMS (migration/transform.go:789).

One case differs. Confirming an outbox send repoints remote_message_id to signal-cli's timestamp but leaves occurred_at_ms at the submit time (outbox.go repointLocalMessage). So for a message this account wrote, a remote ID that is a positive decimal wins over SentAt. I limited that rule to self-authored targets because incoming Signal IDs have been SHA-1 since signalIncomingSourceID was 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):

  • R1 target: the ref names the quoted message's current remote ID, or the stored remote ID when the store holds no such message.
  • R2 described iff resolvable: SentAt is zero exactly when the message is not held or is an outgoing message whose remote ID is still its own outbox request ID.
  • R3 fidelity: a described ref equals the stored message. AuthorID is the sender identity's canonical value ("" for none), SentAt is occurred_at_ms, Text is the body, and the attachment fields are the first attachment's.
  • R4 determinism: a retried attempt carries the same ref.
  • R5 pre-call: a store error while resolving the ref leaves the row not_dispatched with no transport call (TestReplyTargetLoadFailureFailsBeforeTransport).

Quote builder (TestQuoteArgsInvariants: 5,000 seeded random targets, against an independently written oracle):

  • Q1 shape: success is exactly --quote-timestamp <positive decimal> --quote-author <non-empty> --quote-message <non-empty>.
  • Q2 timestamp: a self-authored target whose RemoteID is a positive decimal uses it; every other target uses SentAt. It fails, with "timestamp is unavailable", exactly when the chosen source is not positive.
  • Q3 author: empty AuthorID or the account quotes the account; any other author goes through the contact resolver.
  • Q4 text: the trimmed body, else the attachment placeholder when there is an attachment, else Attachment.
  • Q5 determinism.

Legacy compatibility:

  • L1 the legacy path is unchanged: for 400 random legacy rows, signalQuoteArgs equals 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 legacy SendText/SendMedia paths, and undescribed refs, still use that lookup.
  • L2 differential, new path = legacy lookup for messages both stores hold (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 is signalQuoteArgs on the legacy row. The new side is a real MessageService reply dispatched to a scripted Signal account, with the captured MessageRef sent through the real adapter conversion into the quote builder against an empty legacy store. The arguments are identical, and every ref is described.
  • L3 decoder timestamp: in the same corpus, every v2 message's occurred_at_ms is the sent timestamp of its line.
  • L4 legacy IDs: a signal: ID the legacy store holds quotes from its row (TestReplyQuoteArgsQuotesALegacyIDFromItsLegacyRow).

Tests

  • internal/messaging/reply_ref_test.go covers 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 from outbox_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, and SendTextRequest/SendMediaRequest signal-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 every MessageRef field reaches signallive.ReplyTarget under the same name, and that SendText and SendMedia hand the described ref to the poller.
  • cmd/r5_signal_quote_reply_test.go runs end to end on the real migrated R5 store with a v2-primary stack and a scripted Signal adapter:
    1. A reply through POST /api/v1/outbox/messages to 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.
    2. A media reply (SubmitMediaV2) to a live-ingested incoming message (SHA-1 ID, ACI author, the decoder's timestamp).
    3. Replies to the migrated incoming signal:1700000001000 and the migrated own signal:local:abc123r5, each quoting exactly what its legacy row gives.
    4. A reply submitted while its quoted send was scheduled and pending, which quotes the confirmed transport ID.

Mutation check

Each mutant was applied alone and then reverted:

Mutant Result
Dispatcher always sends a bare ref killed
Outbox request-ID fallback removed killed (messaging + e2e)
Awaiting-transport-ID check removed killed
Numeric remote ID wins for every author killed (rules, property, e2e migrated incoming)
Numeric remote ID never wins killed
Adapter drops SentAt killed (reflection, adapter, L2)
Legacy row ignores IsFromMe killed (L1)
No outbox attachment fallback killed
Described target still goes to legacy killed (incl. L2)
Dispatcher drops the body killed
Dispatcher uses created_at_ms for SentAt killed (incl. L2)
Highest attachment ordinal first killed
Legacy-ID preference removed killed (L4)

Verification

  • 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

Residuals

  • Reactions to incoming Signal messages on v2 pass the SHA-1 remote ID to sendReaction -t, the same class of bug. It is filed as a separate task, which can reuse QuoteArgs's timestamp rule.
  • An incoming message stored with no sender identity (migrated rows whose legacy SenderNumber was empty) quotes as this account, which is what the legacy lookup did for such rows.
  • A quoted send in store_failed has not yet been repointed to its transport ID, so it gets a bare ref until RepairStoreFailed runs.

🤖 Generated with Claude Code

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