Skip to content

Hub: persist agent mail into participant mailboxes (CL-7449) - #638

Merged
TheGreatAxios merged 9 commits into
mainfrom
cl-7449-mailbox-persist-wrap
Sep 5, 2026
Merged

TheGreatAxios merged 9 commits into
mainfrom
cl-7449-mailbox-persist-wrap

Conversation

@TheGreatAxios

@TheGreatAxios TheGreatAxios commented Sep 4, 2026 •

Copy link
Copy Markdown
Contributor

Summary

  • Wraps baseLookups.persistMail with @corbits/mailbox's createMailboxPersist before the composed lookups reach the sidecar handler, so every outbound mail.outbound frame also lands a durable principal_mail row for each addressed human participant, dual-written alongside the existing session_mail write.
  • authorizeSender (apps/hub/src/mailbox-persist.ts) resolves the sender run address through the same resolveRoutableAddress persistMail already uses, then reads that run's tenant's mail domain; a non-live sender resolves to null, skipping the mailbox write while the frame still goes upstream. This is a genuine double resolve against upstream's own internal resolution of the same address — documented in the source as an accepted cost, since sharing one resolution would require changing the vendor-owned persistMail signature.
  • resolveRefs (replacing the earlier onRow stamper) resolves refs: [{ kind: "workbench", id: workbenchId }] inside createMailboxPersist's own transaction, so a bus subscriber's SSE event always carries the ref already, nothing to poll for. It is now header-first (CL-7449 follow-up): @corbits/chat's new resolveWorkbenchIdForAgentFrame reads the frame's own In-Reply-To (or, failing that, the newest References entry) and maps that Message-ID back to the timeline row it answers via findByMailMessageId — that row's own workbenchId is authoritative, since an agent participating in several workbenches at once has no single honest answer from its address alone. Only a header-less frame falls back to the participant scan (ChatStore.findWorkbenchIdsByParticipantAddress), and only when that scan names exactly one workbench; zero or several matches stamp no ref and report mailbox_ref_unresolved (sender/tenant context) instead of guessing. workbenchId is resolved from the sender RUN's own workbench, not senderAuthorization.tenantId: an agent run launches in its parent BENCH tenant (what authorizeSender resolves, and what the mailbox rows themselves are scoped under), and a bench hosts many workbenches — conflating the two would stamp every row with an id no workbench thread read can ever resolve.

Fixes CL-7449
Part of CL-7445

Test plan

  • bun run scripts/typecheck.ts
  • bun run lint (prettier + eslint)
  • bun run check:structural
  • bun test packages/chat/src/resolve-workbench-ref.test.ts — 4/4 pass: header resolves to the parent row's workbench even with two participations; no-header + single participation resolves; no-header + two participations stamps nothing and reports mailbox_ref_unresolved; no-header + zero participations likewise.
  • bun test apps/hub/test/mailbox-persist.test.ts against real Postgres — 7/7 pass, including the new header-first-with-two-workbenches case, dual-write-independence (sessionless sender), redelivery-dedupe, and no-workbench-participation cases, plus asserting the workbench ref is visible to a bus subscriber at event-fire time with no polling.
  • bun test apps/hub/test/composition.test.ts — 11/11 pass, boots the real hub with this wiring.
  • bun test (apps/hub) — 279/279 pass.
  • bun test (packages/chat) — 932/932 pass.

@linear-code

linear-code Bot commented Sep 4, 2026

Copy link
Copy Markdown

CL-7449

CL-7445

@TheGreatAxios
TheGreatAxios force-pushed the cl-7449-mailbox-persist-wrap branch from 509f00f to ec7af82 Compare September 5, 2026 02:33
DB-gated coverage for the composition: an outbound frame addressed to
two humans and one agent lands exactly two principal_mail rows (one
per human), an SSE event per mailbox, and the upstream session_mail
write still runs. Plus unit coverage for authorizeSender resolving a
live sender run and refusing a dead one.
Every outbound agent frame reached the hub as a mail.outbound sidecar
frame and was persisted through the lookups' persistMail hook, which
writes session_mail only -- agent replies never landed in anyone's
mailbox. Wire createMailboxPersist (@corbits/mailbox) around
baseLookups.persistMail before the composed lookups reach the sidecar
handler, so every addressed human participant also gets a durable
principal_mail row, dual-written alongside session_mail in one
transaction with an SSE event on the mailbox bus.

authorizeSender (apps/hub/src/mailbox-persist.ts) resolves the sender
run address through the same resolveRoutableAddress the lookups
already use, then looks up that run's tenant's mail domain -- a
non-live sender resolves to null, which skips the mailbox write while
the frame still goes upstream. onRow stamps refs: [{ kind:
"workbench", id: tenantId }] onto each inserted row so a thread read
can scope by workbench; it owns its own try/catch and reports through
reportError; the persist call itself carries no second try/catch,
since dual-write independence is createMailboxPersist's own contract.
…stamp

tenant.domain is globally unique, so three tests sharing one constant
domain collided in CI's shared database. Mint a fresh domain per
setup() call instead. Separately, createHubMailboxRowRefsStamper's
onRow fires its DB update without the package awaiting it (onRow
returns void by contract), so the refs assertion raced the update;
poll briefly for it to land instead of reading once immediately after
persistMail resolves.
…ery dedupe (CL-7449)

Covers the mailbox-persist wrapper's critique findings: resolveRefs
resolving the sender run's actual workbench (not the bench tenant), a bus
subscriber seeing the ref at event time with no polling, the
dual-write-independence branch (sessionless sender throws upstream, mailbox
rows still land, zero session_mail rows), and same-frame redelivery dedupe.
Also asserts the upstream session_mail row durably, not just via the
returned value.
…saction (CL-7449)

Fixes two critique findings on the CL-7449 wrapper:

- The onRow hook stamped `senderAuthorization.tenantId` (the bench the run
  launched in) as the workbench ref, which is wrong -- a bench hosts many
  workbenches, and the run's own workbench is a separate id
  `@corbits/chat`'s `ChatStore.findWorkbenchByParticipantAddress` already
  tracks. `createHubMailboxResolveRefs` resolves it correctly, replacing
  `createHubMailboxRowRefsStamper`.
- That resolution now runs as `createMailboxPersist`'s `resolveRefs` seam,
  inside the same transaction as the row insert, instead of an out-of-band
  `UPDATE principal_mail` issued after the fact -- so a bus subscriber's SSE
  event always carries the ref already, with nothing to poll for.

Also documents (rather than silently accepting) the one remaining
`resolveRoutableAddress` double-resolve: `authorizeSender` and the
vendor-owned upstream `persistMail` each resolve the sender address once,
and there is no seam to share that result without changing the vendor
signature.
Covers resolveWorkbenchIdForAgentFrame's header-first resolution (an
In-Reply-To/References hit names the parent row's own workbench even
when the sender participates in several), the participant-scan
fallback for header-less frames, and the ambiguous/absent cases
(zero or several participations) stamping no ref and reporting
mailbox_ref_unresolved once.
…7449)

createHubMailboxResolveRefs resolved the workbench a reply's mailbox
rows get stamped with by scanning the sender's workbench participation
and taking the first match -- wrong for an agent in more than one
workbench, since "first" is an arbitrary pick with no relation to which
workbench the reply actually belongs to.

@corbits/chat now exports resolveWorkbenchIdForAgentFrame: it reads the
frame's own In-Reply-To (or, failing that, the newest References entry)
and maps that Message-ID back to the timeline row it answers via
findByMailMessageId -- that row's own workbenchId is authoritative.
Only a header-less frame falls back to the participant scan, and only
when ChatStore's new findWorkbenchIdsByParticipantAddress names exactly
one workbench; zero or several matches stamp no ref and report
mailbox_ref_unresolved instead of guessing.

apps/hub/src/mailbox-persist.ts hands the helper both stores it needs
(chatStore, roomMessages) plus the frame's decoded headers; index.ts
hoists roomMessages next to chatStore so both are ready before the
mailbox-persist wiring is built.
@TheGreatAxios
TheGreatAxios force-pushed the cl-7449-mailbox-persist-wrap branch from ec7af82 to a1ac049 Compare September 5, 2026 04:09
@TheGreatAxios
TheGreatAxios merged commit 48ee0f5 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