From a provider message to a canonical one
Mail exists twice on purpose. tenant_* tables hold what the provider adapter fetched, in the provider's own shape; the canonical threads, messages and contacts are what the engine and every screen read. connect_core.bridge_* copies between them, in one direction, and the engine never reads the provider tables at all.
Two models, one direction#
An adapter's output is shaped by its provider: Gmail's identifiers and labels, Graph's own fields, an IMAP server's UIDs and flags. Storing that faithfully is the right thing to do — it is the evidence of what actually arrived — and building a product on it would be the wrong thing, because every screen and every decision would then carry three shapes.
So the adapter writes tenant_* and stops. connect_core.bridge_* copies into the canonical records, and everything downstream reads only those. The flow is one-way: nothing after the bridge writes back into the provider tables, and nothing before it reads the canonical ones.
| Provider side | Canonical side |
|---|---|
tenant_*, in the provider's shape | threads, messages, contacts |
| Written by the adapter | Written by connect_core.bridge_* |
| Read by the bridge | Read by the engine, every screen, the Assistant and the audit trail |
| Meaningful only alongside its provider | Provider-independent by construction |
| Discarded relevance once bridged | The workspace's actual history, outliving any connection |
What the separation buys#
- Adding a provider changes nothing downstream. The canonical shape is the only thing anything after the bridge has ever seen, so a new adapter is an adapter rather than a migration.
- A bug in one adapter cannot corrupt conversation history. It can corrupt its own table, which is recoverable, rather than the workspace's record of what was said to whom.
- Disconnecting a mailbox does not delete a workspace's past. Canonical records were copied, not referenced — see Disconnecting a mailbox.
- One triage model, not three. A person's own marks —
threads.priority,threads.starred,threads.deleted_at— live on the canonical rows, so they mean the same thing whatever the message arrived through.
The cost is real and worth naming: the same message is stored twice, and the bridge is a place where things can fail. That trade was made deliberately, and the failure mode it buys — a bridge that stops — is loud and local, where the one it avoids is silent and spread across every screen.
What the bridge produces#
- A thread
- The conversation the message belongs to, matched or created — Conversation threads.
- A message
- The canonical record: who, when, what, on which thread, through which mailbox.
- A contact
- The counterparty as a record, which is then resolved to a person and possibly a company — Resolving a sender to a person.
Everything the workspace does afterwards hangs off those three. Triage reads them, the engine grounds a draft against them, memory and knowledge resolve against the people they name, and the audit trail refers to them. None of that machinery has a provider-specific branch, and that is the property being protected.
When something does not appear#
A message that reached tenant_* but is not in Conversations has stopped at the bridge, and that is a different problem from a mailbox that fetched nothing. The distinguishing question is whether anything at all arrived in that window: nothing arriving is a quiet mailbox or a health problem; some things arriving and one not is a canonicalisation problem.
The cursor rule protects you in both cases. A position advances only for work that produced a canonical record, so a message the bridge rejected leaves the position where it was and is offered again rather than passed over — IMAP UID and cursor lifecycle sets out the outcomes.
Questions#
Why store the same message twice?
Because the two copies answer different questions. The provider copy is evidence of exactly what arrived in the provider's own terms; the canonical copy is the workspace's record of the conversation. Collapsing them would make every screen and every decision provider-aware.
Can the engine be pointed at the provider tables for speed?
That is the shortcut the design exists to refuse. The moment the engine reads a provider shape, adding a provider stops being free and a bug in one adapter stops being contained.
Does the canonical record keep the original message?
The provider-side record is what holds the message as the provider expressed it. How the body is displayed to you is a separate concern handled by mail_render.py — Rendering HTML email safely.