Make v2-primary reads resolve legacy conversation IDs, name direct threads, and surface projection lag (#155) - #156
Make v2-primary reads resolve legacy conversation IDs, name direct threads, and surface projection lag (#155)#156MaxGhenis wants to merge 8 commits into
Conversation
… threads (#155) A live install's Signal 1:1 thread became unreadable across app restarts: the API returned [] for `signal:+1555…` and the thread appeared to vanish from /api/conversations. Four separate defects, all consequences of the v2 cutover: 1. v2 reads only ever resolved v2 primary keys. Cutover re-keys every conversation to a derived hash and preserves the legacy id as remote_conversation_id, so every stored pre-cutover reference silently read as an empty thread the moment a restart flipped the app to v2-primary. v2read now falls back to a remote_conversation_id lookup across accounts (deterministic: most recently active wins) and always returns the canonical v2 id in the DTO. 2. Direct conversations had no name. Only the Google decoder emits ConversationEvent frames, so Signal/WhatsApp threads are minted from message frames — which carry no title and no roster. Legacy named 1:1s after the remote peer; mapConversation now does the same, falling back to the peer's address so a thread is never a blank row. 3. Direct conversations had no participants. The projector now links the peer when projecting a direct-conversation message (inbound sender identity, or the peer named by an address-shaped remote id for outgoing-only threads), and a daemon-startup sweep backfills rows that predate the fix. Opaque Google thread ids name no party and are left alone rather than guessed. 4. Post-cutover Signal/WhatsApp groups were stored as `direct`, because message events carry no kind and the projector defaulted every one of them. That mis-renders groups as 1:1s, and would have let the peer repair in (3) attach whoever spoke first as a group's sole counterparty. Kind is now inferred from the remote id where that is authoritative, never for opaque ids, and the sweep corrects mislabeled rows without touching their rosters. Also: /api/status freshness measured only the ACTIVE read source, so a stalled v2 ingest projection was invisible — the legacy path kept ingesting for two days (Thu 7/23 → Sat 7/25) while readers saw nothing new and every platform still reported behind_days 0. Each platform entry now carries legacy_latest_ms, projection_lag_ms, and projection_stalled. The reported "every message shows Wed 19:00" symptom was NOT a data bug: the legacy DTO marshals `TimestampMS`, and a reader looking for `timestamp_ms` gets 0, which formats as epoch 0 in ET. The UI depends on these field names, so they are documented in the runbook rather than renamed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…er (#155) Validating the backfill against a copy of a real store surfaced a fabrication risk in the sweep itself. Google Messages thread ids are opaque about shape, so a conversation stored as direct might really be a group whose ConversationEvent has not arrived. Linking "the latest inbound sender" as its peer would then make that group render as a 1:1 with whoever last spoke. Where the remote ID does not itself confirm direct, the sweep now requires the only evidence available — exactly one distinct inbound sender identity — and leaves multi-sender threads for the ConversationEvent that carries the real roster. On the store checked, all six bare Google threads had a single inbound sender and still link correctly (12 linked, 4 mislabeled group kinds corrected, 1 unattributed thread left alone, second run a no-op), so this is a guard against the case that store happened not to contain. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…155) The helpers landed in the prior commit without their call sites — my staging omission, so the projector was still minting kindless, rosterless conversations. ensureMessageConversation now infers kind from the remote ID, and messageProjection links the direct peer (inbound sender identity, or the peer named by an address-shaped remote ID when there is no attributed sender). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The mislabeled-group case was written with a real Signal group ID copied from the store I validated against. This is a public repo; test fixtures must not carry real identifiers. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A parallel agent's working notes on the reconciliation follow-up; not part of this change and not something the repo should carry. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This reverts commit d47a134.
|
Scope note. A parallel agent committed the in-progress reconciliation package onto this branch mid-review. I reverted it here ( Worth recording from that work, because it is the sharp edge of any future reconciliation: v2 and legacy disagree on outgoing Signal message keys. The live decoder keys a sync-sent message by its bare send timestamp ( |
Restores the command file alongside its package on this WIP branch. The reconciliation work is parked here rather than in #156: that PR is scoped to read durability, and a data-mutating maintenance command needs its own review. Status before this can ship: - verify the backend-stopped guards (instance lock + probe) match cmd/migrate.go exactly, including exit codes - decide whether attachments stay deferred (MediaDeferred is counted, not reconciled) or are carried over - a dry run against a copy of a real store, then a supervised real run The dual-key dedup contract is the load-bearing part and is tested: v2 keys an outgoing Signal message by its bare send timestamp while legacy keys it "local:<sha1>", so both forms are checked before import. Without that, every outgoing message already projected by the live decoder re-imports as a duplicate. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Adversarial review (gpt-5.6-sol, ultra reasoning) — REQUEST-CHANGES, not safe to redeploy yet. Five findings:
Plus: the alias resolver suppresses account/lookup errors as "no match" (alias.go ~32); cross-account selection intentionally rebinds on activity (consumers must pin the returned canonical id) but the test doesn't prove recency-wins (freshest match is also last in account order) and lacks a tie case. Missing coverage: WhatsApp out/in sequence, Google message-without-conversation, post-sweep live message, outbound-masked stall, alias search/deep links, collision tie. Full review transcript available locally (sol-review-156). The four-defect diagnosis in the PR body stands — these findings are about the fix's edge cases, not the diagnosis. |
Closes #155.
The report
A Signal 1:1 thread readable on 7/25 via
GET /api/conversations/signal%3A%2B1XXXXXXXXXX/messagesreturned[]on 7/26after two app restarts, and the thread was absent from
/api/conversationswhile
/api/statusinsisted Signal wasconnected: true, paired: true. The MCPget_conversationpath was equally empty.No messages were lost. Four distinct defects, all consequences of the
v2-primary cutover, made an intact thread unaddressable.
What was wrong
1. v2 reads only ever resolved v2 primary keys. Cutover re-keys every
conversation to
v2keys.DeriveID(...)(a 32-hex hash) and preserves the legacyid as
conversations.remote_conversation_id.v2read.Sourcelooked up only theprimary key, so every stored pre-cutover reference — the exact string agents and
notes had been carrying for days — silently read as an empty thread the moment a
restart flipped the app to v2-primary. Reads now fall back to a
remote_conversation_idlookup across accounts (deterministic: most recentlyactive wins, so a number that threads on two platforms can't flap), and the
returned DTO always carries the canonical v2 id.
2. Direct conversations had no name. Only the Google decoder emits
ConversationEventframes; Signal and WhatsApp conversations are minted frommessage frames, which carry no title. Legacy named 1:1s after the remote peer,
so post-cutover Signal threads listed as blank rows.
mapConversationnow namesdirect threads after the peer, falling back to the peer's address.
3. Direct conversations had no participants. Same root cause — message
frames carry no roster, so 1:1 threads read
Participants: "[]". The projectornow links the peer when projecting a direct-conversation message (inbound sender
identity; for outgoing-only threads, the peer named by an address-shaped remote
id), and a daemon-startup sweep backfills rows that predate the fix.
4. Post-cutover Signal/WhatsApp groups were stored as
direct. Messageevents carry no kind and the projector defaulted all of them, so groups
mis-render as 1:1s — and it would have let the peer repair in (3) attach whoever
spoke first as a group's sole counterparty. Kind is now inferred from the remote
id where that is authoritative (
signal-group:,@g.us,@broadcast), neverguessed for opaque Google thread ids, and the sweep corrects mislabeled rows
without touching their rosters.
Plus the reason nobody noticed for two days:
/api/statusfreshness measuresonly the active read source. Running v2-primary, a stalled v2 ingest projection
is invisible there — the legacy path kept ingesting from Thu 7/23 to Sat 7/25
while readers saw nothing new, and every platform still reported
behind_days: 0. Each platform entry now also carrieslegacy_latest_ms,projection_lag_ms, andprojection_stalled, plus a top-levelprojection_stalled.The "placeholder timestamps" were a reader bug, not a data bug
Reported as "every message shows
Wed 19:00". The legacy DTO marshalsTimestampMS(Go field name, no tag); the reading script looked fortimestamp_ms/TimestampMs, got0, andfromtimestamp(0)renders as1969-12-31 19:00 ET. Stored timestamps were correct the whole time. The web UI
reads
TimestampMSin 15 places, so renaming it would break the app — the fieldnames and this failure signature are documented in the runbook instead.
Verification
Unit and integration tests, plus the Signal worker harness driving real
signal-cli envelopes end to end: peer linked for an inbound 1:1, peer derived
from the remote id for an outgoing-only thread, no participant synthesized for a
group, opaque Google ids left alone, alias resolution for legacy/v2/group keys,
deterministic cross-account resolution, projection-lag reporting, and
idempotence of the sweep.
GOWORK=off go test ./...is green.Also validated against a read-only copy of a live store (temp harness,
removed before commit — the app was never touched):
signal:+1XXXXXXXXXX→ resolves to its v2 hash, correctTimestampMS, namedafter the peer once the sweep runs.
group kinds corrected, 1 unattributed opaque thread left alone, second run a
no-op.
signal-group:row stored asdirectflipped togroup: true.That exercise also caught a fabrication risk in my own sweep: for opaque Google
thread ids, group-ness is unknown, so linking "the latest inbound sender" could
render a group as a 1:1. The sweep now requires the only available evidence —
exactly one distinct inbound sender — before naming such a thread's peer.
Live-app note
Per the working agreement: the running app was not touched — no restart, no
stop, no write to the live data dir. All validation ran against copies in a temp
dir. The participant/kind sweep runs on the next ordinary app start; nothing here
requires a manual step.
Still open (follow-up, not in this PR)
The v2 store is missing the Signal messages from the stall window that only
legacy has (~98 in the thread that prompted this, Thu 20:17 → Sat 15:00). Reads
are durable now, but that history is genuinely absent from the store the app
reads. An
openmessage v2 reconcile-signalmaintenance command is in progress ona parallel track; key parity is already verified — legacy
source_idisbyte-identical to what the live decoder derives for both directions
(
v2keys.SignalIncomingSourceIDinbound,v2keys.SignalLocalAliasoutbound), soreconciliation can dedupe exactly rather than duplicate.
Note: this branch also contains one commit (
PROGRESS.md) from the parallelagent working on that command.
🤖 Generated with Claude Code