Skip to content

fix(api): build the bridge for a contact's first outgoing message - #403

Merged
setkyar merged 1 commit into
mainfrom
hotfix/first-outgoing-message
Sep 15, 2026
Merged

setkyar merged 1 commit into
mainfrom
hotfix/first-outgoing-message

Conversation

@setkyar

@setkyar setkyar commented Sep 15, 2026

Copy link
Copy Markdown
Contributor

The bug

The first outgoing message to a brand-new contact fails with 404 from POST /api/messages. Every later message to the same contact works, which is what made it read as a contact problem rather than a bridge one.

The outbound "new chat" flow (findOrCreateContactByPhone) inserts a contacts row and nothing else. The channel-spine bridge — including the conversations row — is otherwise only ever written as a shadow of a legacy message write. So a contact that has never exchanged a message has no conversation, and under neutral write authority sendChannelContactMessage looked one up, found nothing, and returned notFound(c, "Contact or JID").

The fix

ensureChannelConversationId() resolves the conversation through the bridge, building it with shadowLinkedDeviceWorkflow when the contact has none.

Deliberately not shadowLinkedDeviceLegacyMutation: that one is best-effort and swallows failures into the reconciliation journal, which is right for a shadow of an already-authoritative legacy write and wrong here, where the send depends on the conversation existing. An unresolvable bridge fails the request instead.

Every identity in the bridge is a deterministic UUID and every write upserts, so two first sends racing each other converge on the same conversation. Unchanged behaviour elsewhere: a non-WhatsApp contact (no jid / no connection) still resolves to nothing and still 404s, and an archived conversation still falls through to the existing notFound(c, "Conversation").

Verification

New apps/api/src/routes/messages/first-neutral-send.integration.test.ts drives the real route under neutral authority against a connected account and a contact with no conversation row:

  • without the patch: POST /api/messages404, test fails
  • with the patch: 200, conversation created against the right channel account, message stored

Also green: send.integration.test.ts, neutral-whatsapp-send.integration.test.ts, first-chat-acknowledgment.integration.test.ts, scheduled.integration.test.ts, the full API unit suite (947 pass / 0 fail), tsc --noEmit, and git diff --check.

Follow-ups (not in this diff)

  • Scheduled sends (routes/messages/scheduled.ts) and the MCP send (routes/mcp/tools/write.ts) read the same bridge but tolerate a null conversation. Neither was verified against the bridgeless state.
  • The send guard separately requires an active conversation case; a contact with no case gets a clear 409, not a 404. Unchanged here.

The outbound "new chat" flow inserts a `contacts` row and nothing else,
and the channel-spine bridge is otherwise only ever written as a shadow
of a legacy message. A contact that has never exchanged a message
therefore has no conversation row, so under neutral write authority the
send had nothing to write against and answered 404 - the first message
to a new contact was impossible while every later one succeeded.

Resolve the conversation through the bridge instead, building it when
the contact does not have one yet. Unlike `shadowLinkedDeviceLegacyMutation`
this is not best-effort: the send depends on the conversation, so an
unresolvable bridge fails the request rather than being journalled.
Every identity in the bridge is deterministic and every write upserts,
so two first sends racing each other converge on the same conversation.

The regression test drives the real route against a contact with no
conversation: 404 before the change, 200 after, with the conversation
and message written against the right channel account.

Scheduled sends and the MCP send read the same bridge but tolerate a
null conversation; neither was verified against this state and both are
left for a follow-up.
@setkyar
setkyar merged commit 2c3fabc into main Sep 15, 2026
9 checks passed
@setkyar
setkyar deleted the hotfix/first-outgoing-message branch September 15, 2026 18:27
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