Skip to content

Chat: write sent messages into participant mailboxes (CL-7450) - #640

Merged
TheGreatAxios merged 13 commits into
mainfrom
cl-7450-chat-send-mailbox
Sep 5, 2026
Merged

TheGreatAxios merged 13 commits into
mainfrom
cl-7450-chat-send-mailbox

Conversation

@TheGreatAxios

@TheGreatAxios TheGreatAxios commented Sep 4, 2026 •

Copy link
Copy Markdown
Contributor

Fixes CL-7450
Part of CL-7445

Summary

  • Ports the storage-only slice of RFC 5322 Message-ID threading from PR CL-7107: re-pin Interchange vendor to 692c3106 #632's branch (cl-7107-repin-and-re-vendor-audit-against-interchange-main-before-the, commits 3a604647 and 5bec1a73): the workbench_messages.mail_message_id column and its migration (0029), stampMailMessageId/findByMailMessageId on both the drizzle and in-memory room-message stores, packages/chat/src/mail-headers.ts (+ its unit tests), and mailAncestryOf in threads.ts.
  • sendWorkbenchMessage runs a strict order: store the row -> stamp its own Message-ID -> fan it into every human participant's @corbits/mailbox inbox as ONE batch (one @corbits/mailbox transaction, via writeMailboxMessages) -> publish the row onto the room's live stream -> dispatch to whichever agents it names. A fan-out failure deletes the just-inserted (never-published) row and fails the send outright — no phantom bubble, no duplicate row on a client retry.
  • Every copy — an "outbound" row in the sender's own mailbox, "inbound" rows in every other human's — carries the row's own Message-ID as its frame header and cached column; idempotency rides @corbits/mailbox's own default transport key, never a caller-minted one. A reply carries In-Reply-To/References from the thread chain.
  • A shared (projected-tenant) workbench's rows live in the OWNING tenant: the Message-ID/In-Reply-To and every recipient's address are minted against the owning tenant's own mail domain, never the acting caller's. The acting sender's principal is resolved in the owner tenant; a share member with no principal there has its own outbound copy skipped quietly (debug log, not a report) while every other human still gets theirs.
  • A bodyless (attachment-only) message gets a real subject (Attachment: <name>) and a body listing its parts, instead of (no subject)/an empty body.
  • The write sits behind a small MailboxWriter port (packages/chat/src/mailbox-fanout.ts) — a thin pass-through onto @corbits/mailbox's own writeMailboxMessages batch call, no hand-rolled inserts — so the fan-out logic (who gets a row, which direction, the shared Message-ID and refs) is unit-testable without Postgres.
  • A participant address with no matching tenant principal is reported and skipped; any other batch failure is reported and rethrown, never swallowed.
  • apps/hub/src/index.ts wires the new mailbox dep (writer + resolveKnownPrincipalIds + resolveTenantDomain) onto the existing mailboxDb/mailboxBus, kept to the minimum lines needed since another branch (.worktrees/cl-mailbox-persist-wrap) is also touching this file's persistMail wrapper.
  • Bumps @corbits/chat's @corbits/mailbox pin to 118a2cd3, matching @corbits/inbox's.

Test plan

  • packages/chat unit suite (non-UI): 924 pass, 0 fail (bun test packages/chat/src packages/chat/test)
  • DB-gated suite against local Postgres (DATABASE_URL=postgres://postgres:postgres@localhost:5432/workbench): all 3 mailbox-fanout.drizzle.test.ts cases pass, including the folded critique frame-Message-ID probe and the batch partial-failure/retry probe
  • packages/chat and apps/hub (@workbench/hub) typecheck clean
  • bun run lint clean (pre-existing unrelated warnings only)
  • bun run check:structural clean (0 new report-error findings)
  • gh pr checks --watch --fail-fast (running next)

Follow-up fixes (critique findings)

  • Finding 1 (HIGH): the dispatched agent frame now carries the row's
    own RFC 5322 Message-ID and In-Reply-To/References, ported from PR
    CL-7107: re-pin Interchange vendor to 692c3106 #632's dispatch-path commits (3a604647) onto this branch's already-
    landed storage slice — dispatchTurn -> MailContent (codec.ts) ->
    WorkbenchMail.sendMail (platform-adapter.ts) ->
    SendFoldedMailParams (folded-runs/src/mail.ts) -> the native
    UserMessageParams.messageId/inReplyTo/references vendor's
    hub-sessions already accepts.
  • Finding 2 (MEDIUM): the mailbox fan-out now writes References
    (the full ancestry chain, oldest first) alongside In-Reply-To.
  • Finding 3 (MEDIUM): a fan-out failure is now a typed
    MailboxFanoutFailedError carrying its own refId, so the send route
    quotes that ref in a 502 rather than letting the failure fall through
    to the hub's generic unhandled-route-error handler and get reported a
    second time. The "nothing has seen it yet" doc/comment claim is
    corrected: chat and @corbits/mailbox are separate Postgres handles in
    the hub's composition, so this is not transactional and the honest
    (bounded) window is documented instead of papered over.
  • Finding 4 (LOW): migration 0029_workbench_messages_mail_message_id
    is shared BY NAME with PR CL-7107: re-pin Interchange vendor to 692c3106 #632's branch
    (cl-7107-repin-and-re-vendor-audit-against-interchange-main-before-the)
    — that branch should rebase onto this one once it merges, not the
    reverse, to avoid a duplicate/conflicting 0029.

@linear-code

linear-code Bot commented Sep 4, 2026

Copy link
Copy Markdown

CL-7450

CL-7445

@TheGreatAxios
TheGreatAxios force-pushed the cl-7450-chat-send-mailbox branch 2 times, most recently from 291f6df to d952af7 Compare September 5, 2026 03:41
Hand-ported the storage-only pieces of RFC 5322 Message-ID threading
from the entangled 3a60464 / 5bec1a7 commits on
cl-7107-repin-and-re-vendor-audit-against-interchange-main-before-the:
the workbench_messages.mail_message_id column and its migration
(0029), stampMailMessageId/findByMailMessageId on both the drizzle and
in-memory room-message stores, mail-headers.ts (mailMessageIdFor,
rowIdFromMailMessageId, mailThreadHeaders, parentMailMessageId,
parseReferences) with its unit tests, and mailAncestryOf in threads.ts.

Left behind: the vendored-pin changes on that branch, and every
dispatch-path change depending on the new pin (chat-orchestrator,
turn-queue, folded-runs mail, interaction-tools) — those are out of
scope here and land with their own ticket.
The storage port (mail-headers.ts, stampMailMessageId/findByMailMessageId,
the 0029 migration) needs two follow-up fixes in existing tests:
chat-orchestrator.test.ts's hand-built RoomMessageStore fake was missing
the two new methods, and migrations.test.ts's expected migration-name
list predates 0029.
Covers `writeChatMailboxFanout` against an in-memory `MailboxWriter`
(no live `@corbits/mailbox` schema needed): a send to a three-human,
one-agent workbench yields three mailbox writes sharing one Message-ID
with the right directions (the sender's own copy outbound, everyone
else's inbound) and a `{ kind: "workbench", id }` ref, the agent gets no
mailbox row at all, a retried send is idempotent on messageKey, a
participant with no known tenant principal is reported and skipped
rather than attempted, a write failure propagates rather than being
swallowed, and `inReplyTo` threads through to every recipient's write.
Also covers `sendWorkbenchMessage`'s own wiring: posting a message
stamps the row's RFC 5322 Message-ID and fans it out under that same id.
sendWorkbenchMessage now stamps the sent row's RFC 5322 Message-ID
(mailMessageIdFor, from the CL-7450 storage port) and fans the message
into every human participant's @corbits/mailbox inbox before returning:
an "outbound" copy in the sender's own mailbox, "inbound" copies in
every other human's, all sharing that Message-ID as both the frame's
threading identity and the mailbox write's idempotency key. A message
answering another one carries In-Reply-To/References built from
mailAncestryOf's thread chain. An agent participant gets no mailbox row
here — its inbox is its run's own mail queue, reached through
WorkbenchMail.sendMail instead.

The write itself sits behind a small MailboxWriter port
(packages/chat/src/mailbox-fanout.ts) rather than calling
@corbits/mailbox directly, so the fan-out logic (who gets a row, which
direction, the shared Message-ID and refs) is unit-testable without a
live Postgres schema. writeMailboxMessage always writes "inbound" (it
has no direction parameter), so the sender's own "outbound" copy is
inserted directly against @corbits/mailbox's exported schema tables,
mirroring the same eager-management-row shape and best-effort bus
publish writeMailboxMessage itself uses.

A participant address with no matching principal in the tenant (a stale
or removed member) is reported and skipped; a write to a known
principal that fails is reported and rethrown rather than swallowed
into a send that looks fully delivered when it wasn't.

apps/hub/src/index.ts wires the mailbox dep onto the same
mailboxDb/mailboxBus every other mailbox consumer there already shares,
resolving known principals against the hub's own `principal` table
directly (kept to the minimum lines needed, per the mailbox-persist-wrap
work in progress on this same file elsewhere).
Documents that a person's message is written to every participant's
mailbox after it lands on the room's own timeline.
prettier --write, no behavior change.
…x (CL-7450)

CI's e2e job caught what no local test did: every chat message send
returned 500 once the mailbox fan-out landed, because writeOutbound's
onConflictDoNothing named (tenant_id, principal_id, message_key) with
no `where` clause, while the live index behind that name is PARTIAL
(`WHERE message_key IS NOT NULL`, matching writeMailboxMessage's own
insert) — Postgres refuses to match an ON CONFLICT target against a
partial index unless the same predicate is restated on the statement,
and fails the whole insert instead of silently ignoring it.

Adds the missing `where` clause, and a DB-gated test
(mailbox-fanout.drizzle.test.ts) against a real @corbits/mailbox schema
that exercises exactly this path — writeOutbound and writeInbound both
writing and deduping on a retried messageKey — which the unit suite's
in-memory MailboxWriter fake could never have caught, since it has no
database to reject a mismatched ON CONFLICT against.
Rewrites the fan-out suite for batch semantics (one writeBatch call, one
transaction: a failed batch writes nothing, a retry after success writes
nothing twice), adds same-tenant vs. share-member sender coverage, an
attachment-only body/subject case, and a sendWorkbenchMessage-level test
that a fan-out failure deletes the just-inserted row and never publishes
it. Folds in the critique's DB-gated frame-Message-ID probe (stored
frame header, cached column, and In-Reply-To all agree with the caller's
own messageId) into mailbox-fanout.drizzle.test.ts, and rewrites its
partial-retry probe for batch semantics instead of per-call writes.
…domain

Four HIGH/MEDIUM findings against the mailbox fan-out:

- Frame Message-ID now equals the row's own Message-ID everywhere: every
  copy goes through @corbits/mailbox's writeMailboxMessages batch with
  the caller's messageId, so the stored frame header, the cached
  message_id column, and a child's In-Reply-To always agree. Deletes the
  hand-rolled writeOutbound insert against principalMail entirely, and
  drops the idempotency key override in favor of the package's own
  default transport key.

- Ordering: sendWorkbenchMessage now stores the row, stamps its
  Message-ID, fans it into every human mailbox as ONE batch/transaction,
  and only THEN publishes and dispatches. A fan-out failure deletes the
  just-inserted (never-published) row and fails the send outright — no
  phantom bubble, no duplicate row on retry. room-messages.ts splits
  postRoomMessage into insertRoomMessageRow/publishRoomMessageEvent (used
  by the send path) and gains RoomMessageStore.deleteMessage; every other
  caller of postRoomMessage is unaffected.

- Shared (projected-tenant) senders: a row's Message-ID/In-Reply-To and
  every recipient's address are now minted against the row's OWNING
  tenant's domain (MailboxFanoutDeps.resolveTenantDomain), never the
  acting caller's own tenant. The acting sender's principal is resolved
  in the owner tenant; when a share member has no principal there, its
  own outbound copy is skipped quietly (debug log) while every other
  human still gets their row.

- Bodyless (attachment-only) messages now get a real subject
  ("Attachment: <name>") and a body listing their parts, instead of
  "(no subject)" and an empty body.

Bumps @corbits/chat's @corbits/mailbox pin to 118a2cd3, matching
@corbits/inbox's.
Describes the strict store -> stamp -> fan-out -> publish -> dispatch
order, the one-batch/one-transaction write, and the owning-tenant domain
rule for a shared workbench's rows.
… fan-out errors (CL-7450)

Covers the three remaining critique findings before their implementation
lands: dispatchTurn's sendMail carrying the dispatched frame's own
Message-ID/In-Reply-To/References (packages/chat/test/dispatch-turn-mail-headers.test.ts),
folded-runs' sendUserMessage receiving those fields verbatim
(packages/folded-runs/test/mail.test.ts), and the mailbox fan-out batch
carrying References plus a typed MailboxFanoutFailedError instead of the
raw writer error (packages/chat/test/mailbox-fanout.test.ts).
…box fan-out failures (CL-7450)

Finding 1 (HIGH): the dispatched agent frame never carried the row's own
RFC 5322 Message-ID — mail-headers.ts's mailThreadHeaders/mailMessageIdFor
had no callers on the dispatch path. dispatchTurn now derives the same
Message-ID mailboxFanOutForSend already stamps for the source row
(mailMessageIdFor(sourceMessageId, domain)) and its In-Reply-To/References
from that row's own thread ancestry, and passes them through MailContent
(codec.ts) -> WorkbenchMail.sendMail (platform-adapter.ts) ->
SendFoldedMailParams (folded-runs/src/mail.ts) -> the native
UserMessageParams.messageId/inReplyTo/references vendor/intx/hub-sessions
already accepts. Degrades to unthreaded, as before, on a composition with
no mailbox domain to derive it from. folded-runs' own replyTo -> inReplyTo
mapping is kept as a fallback for the unrelated mention-fan-out room hint,
now only used when no real inReplyTo is supplied.

Finding 2 (MEDIUM): mailbox rows carried only In-Reply-To; the fan-out now
also writes References — the full ancestry chain, oldest first — onto
each batch item and the writer's write-through.

Finding 3 (MEDIUM): a mailbox fan-out failure now surfaces as a typed
MailboxFanoutFailedError carrying the one refId writeChatMailboxFanout
already reported under, so the send route (and the block-response answer
path) quote that ref in a 502 consumer-facing envelope instead of letting
it fall through to the hub's generic unhandled-route-error handler and
get reported a second time under a different ref.
…t window (CL-7450)

Documents the dispatched frame's own Message-ID/In-Reply-To/References
(finding 1) and rewrites the fan-out-failure paragraph that claimed
"nothing has seen it yet" before the delete: chat and @corbits/mailbox
are separate Postgres handles in the hub's composition, so a concurrent
GET of the timeline in the window between the row insert and a fan-out
failure's delete can read it, even though no chat.message publish (and so
no live client) does. Notes the single refId a MailboxFanoutFailedError
now carries through to the send route instead of a second report.
@TheGreatAxios
TheGreatAxios force-pushed the cl-7450-chat-send-mailbox branch from d952af7 to 49c6d76 Compare September 5, 2026 03:50
@TheGreatAxios
TheGreatAxios merged commit f3043c6 into main Sep 5, 2026
7 checks passed
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