Skip to content

docs: propose a thread participants join - #80

Open
A1exZabr wants to merge 1 commit into
trycompai:mainfrom
A1exZabr:adr/thread-participants
Open

docs: propose a thread participants join#80
A1exZabr wants to merge 1 commit into
trycompai:mainfrom
A1exZabr:adr/thread-participants

Conversation

@A1exZabr

@A1exZabr A1exZabr commented Aug 7, 2026

Copy link
Copy Markdown

Threads should know all their participants

I built a multi-channel CRM a while back (Telegram, MAX, Avito), so I have been reading
this repo with the channel requests in mind. WhatsApp is still open in #50, Outlook was
asked for in #47 and has now shipped, and the mailbox code was just pulled out into a
provider-agnostic mailbox/ module to make room for it.

That refactor is what prompted this. The provider abstraction is there now, but one
assumption came through it unchanged, and it is already wrong with just email.

A thread is attached to exactly one contact. thread-writer.service.ts collects every
participant:

const participants = [parsed.from, ...parsed.recipients];

and mailbox-match.service.ts hands back a single id:

const contact = await this.db.contact.findFirst({
  where: { email: { in: external.map((person) => person.email) } },
});

findFirst with no orderBy, so a thread with three known contacts lands on whichever
row Postgres returns first. The other two exist only as strings inside
EmailMessage.recipients.

That matters more here than it would in most CRMs, because of what read_crm_history is
for. identity-matching.md calls it the strongest evidence available anywhere and says
to start every match there rather than at a search engine. It reads
emailThread.findMany({ where: { contactId } }). For the two participants who lost the
coin flip the thread does not exist. The agent reports no history and goes off to pay for
a lookup, while the reply that would have settled it is sitting in the database.
crm.thread-reply is unavailable for exactly the people who did reply.

What I would do is add a join table between threads and contacts, and keep
EmailThread.contactId as the primary contact so nothing existing breaks. The sync
writes a row per participant it can resolve. read_crm_history reads through the join
instead of the direct foreign key.

What it costs: a migration and a backfill over existing threads, and any query that
assumes one contact per thread has to decide whether it wants the primary or all of them.
The agent tools are the interesting part of that decision, not the UI.

I hit this in my own CRM early and ended up with a ConversationContact join for the
same reason. A conversation with two people from the client company is not an edge case,
it is Tuesday. Once a channel arrives that has no To field to pick a winner from, there
is nothing left to guess with.


Summary by cubic

Adds an ADR proposing a join table so threads track all participants, while keeping EmailThread.contactId as the primary contact. This avoids random single-contact assignment and lets tools like read_crm_history and crm.thread-reply find threads for every participant.

  • Migration
    • Add ThreadContact join table and backfill existing threads.
    • Update sync to write one row per resolved participant.
    • Change read_crm_history to read via the join.

Written for commit c1ab1fa. Summary will update on new commits.

Review in cubic

@vercel

vercel Bot commented Aug 7, 2026

Copy link
Copy Markdown

@A1exZabr is attempting to deploy a commit to the Comp AI - PoC Team on Vercel.

A member of the Team first needs to authorize it.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No issues found across 1 file

Re-trigger cubic

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