Data model: Tenant, Principal, Email triage¶
Status: Draft · supports ADR-0017 (employees as principals) · Date: 2026-07-31
Diagram: docs/architecture/diagrams/abos-er.svg (source: abos-er.dot)
This document predates ADR-0032 and needs reconciling
It was written before the work item existed. Its Message entity does the
job now split between work_item (one conversation, our unit of work) and
message (one email). Classification and Draft hang off Message here,
where they should hang off work_item.
The Identifiers and threading section below is current. The entity list and the ER diagram are not. Tracked as OI-071.
This document is the narrative companion to the ER diagram. It describes how an employee (Principal) and a triaged email are represented across the two planes, and, critically, which relationships are enforced foreign keys and which are logical references across a database boundary.
Identifiers and threading¶
Three identifiers, and confusing them is the usual mistake.
| ID | Identifies | Created by |
|---|---|---|
work_item.id |
One conversation, our unit of work | Us |
message.message_id |
One email | The sending mail server, as an RFC 5322 header |
message.references[] |
The chain of earlier Message-IDs | The sending mail client |
The first email in a conversation creates a work item. A reply joins that same work item rather than creating another. So one work item holds several messages.
flowchart LR
WI[("work_item")] --> MSG[("message<br/>one row per email")]
MSG --> LAND[("landing<br/>raw, untouched")]
MSG -.->|"indexed"| IDX[("message_id lookup")]
Three layers, three jobs. Landing holds the raw message untouched, including every header. message holds what you query on. The index makes arrival-time matching fast.
What to extract per message¶
| Field | Why |
|---|---|
message_id |
The matching key. Indexed, unique per tenant |
in_reply_to |
Direct parent |
references[] |
The full ancestor chain, and what arrival matches against |
direction |
inbound or outbound |
sent_at |
From the Date header. Relative dates resolve against this, never against processing time |
from, to, cc |
Sender resolution, and who else is on the conversation |
subject |
Not used for threading. Useful for display, and as a fact |
provider_thread_id |
The provider's own grouping ID: conversationId on Microsoft Graph, threadId on Gmail. Secondary signal only (ADR-0032, OI-070) |
Matching on arrival: take the incoming references[] plus in_reply_to, look each up in the index, and the first hit gives the work item.
Store the Message-ID of what we send
If a reply is sent without recording its Message-ID, the customer's reply to
our reply references an ID we have never seen, and it starts a new work
item.
Half of all conversations would break, and only on the second exchange, which
is a miserable thing to debug. The mail connector's send_draft returns it
for exactly this reason.
Providers name it differently, and it is the same RFC 5322 field: Microsoft
Graph calls it internetMessageId, Gmail returns it in the message headers.
We store message_id, which is the standard name.
Scope the index per tenant
Message-IDs are globally unique in theory and not in practice. A collision across tenants would be a cross-tenant leak, so the uniqueness constraint and every lookup are scoped to the tenant.
Do not copy the whole header block into the modelled layer. Landing already holds it, and duplicating means two places to keep in step.
Proposed schema¶
Proposed, not built
This is the shape implied by ADR-0032 and the sections above. It supersedes the entity list further down, which predates the work item (OI-071). Column types are indicative.
erDiagram
work_item ||--o{ message : "holds"
work_item ||--o{ decision : "one per turn"
work_item ||--o{ approval : "one per gated action"
work_item }o--o| job : "may reference"
job }o--|| customer : "belongs to"
message ||--|| landing_email : "raw"
work_item¶
One conversation. The unit the Progress engine moves through states.
| Column | Notes |
|---|---|
id |
Ours |
principal_id |
RLS boundary (ADR-0017) |
source |
email, later form, webhook |
status |
new → assessing → awaiting_approval → sent_by_us | closed_by_human | closed_manual | closed_no_action |
attention_reason |
Nullable. Null means routine. Why this item needs care, kept separate from status because a flagged item can still be sent by us |
attention_detail |
Free text for the human |
brain_calls |
Times the brain was invoked. A ceiling halts a looping item (ADR-0032 §3) |
model_calls |
Actual model invocations. The cost figure, since a brain call may make zero, one or two |
tokens_in, tokens_out |
Cumulative, so cost per item is a fact rather than an estimate |
config_version |
Pinned at first brain call. Null means not ready, and nothing may act on it |
catalogue_version |
Pinned likewise |
category |
Latest assessment. A label, and it decides nothing on its own |
current_stage |
Denormalised from the latest stage_trace row. Exists so the live funnel view is a simple group-by rather than a per-item max over the whole trace table |
priority |
A property of the item, never an action parameter |
job_id |
Nullable. Many items have no job |
job_link_basis |
How the link was derived: quoted_reference, sole_open_job, human_confirmed |
job_link_certain |
false means the draft hedges, and the approver sees it |
created_at, closed_at |
closed_at is cleared on reopen |
first_closed_at |
Set once, never cleared. Without it, a reopened item cannot be distinguished from one that never closed, and reporting double-counts |
reopen_count |
How many replies arrived after a close |
status distinguishes closed_manual (the human replied first) from closed_by_human (the human handled it instead of using our draft). Different outcomes with different meanings in reporting, and collapsing them hides a real signal.
message¶
One email. Several per work item.
| Column | Notes |
|---|---|
id |
Ours |
work_item_id |
|
message_id |
RFC 5322. Unique per tenant, indexed |
in_reply_to |
|
references |
Array. Matched against on arrival |
direction |
inbound | outbound. Outbound rows are what stop the second exchange breaking |
sent_at |
From the Date header. Relative dates resolve against this |
from_address, to_addresses, cc_addresses |
|
subject |
Display and facts only, never threading |
conversation_id |
Provider ID. Secondary signal |
landing_ref |
The raw message |
decision¶
One per brain call. Append-only.
| Column | Notes |
|---|---|
id, work_item_id, brain_call |
|
idempotency_key |
work_item_id + brain_call. Unique. A retry returns the stored row rather than deciding again (ADR-0032 §6c) |
facts |
Deterministic, from code |
observations |
From the model |
prompt_user_block |
The per-item half of the prompt: facts, state, permitted actions, the masked message. Stored deliberately, so a decision can be explained and the calibration loop has something to work from |
model_response_raw |
What came back, before parsing. Enough to see why it decided as it did |
rules_fired |
Which rules matched, by ID and tier. Required, or rule health cannot be measured and a bad outcome cannot be traced back to the rule that caused it |
assessment |
Category, priority, confidence, summary |
action_name, action_params |
Nullable, since not every brain call produces an action |
model, prompt_version, tokens_in, tokens_out, model_calls |
Cost and eval. model_calls on the decision is what tells you whether a crashed call had already paid |
created_at |
Splitting facts from observations matters for eval: facts are objectively right or wrong and can be scored without a human, while observations need judgement.
The system block is not stored, and does not need to be
Only the user block is kept per decision. The system block is
reconstructible from prompt_version, and storing it per row would duplicate
the same six sections thousands of times (prompt anatomy).
That is the cache boundary earning its keep twice: once on cost, once on storage.
Where prompt content may and may not go¶
A rule that needs sharpening rather than reversing.
| Destination | Prompt content? |
|---|---|
| Sentry, application logs, the Console | Never |
Tenant Postgres decision |
Yes, deliberately. Same residency, backup, access and exit rules as everything else in that database |
| Langfuse | Short retention, debugging only. Never the durable record |
"Never log prompt content" was always about incidental surfaces, where data ends up somewhere with different rules by accident. Deliberate storage in the tenant's own database, under ADR-0008, ADR-0012 and ADR-0026, is a different thing.
This is what makes calibration possible. Without the input and the raw response, a wrong classification cannot be diagnosed, and there is nothing to improve a prompt against.
stage_trace¶
One row per stage, written as it is reached. Around ten per exchange.
| Column | Notes |
|---|---|
id, work_item_id |
|
brain_call |
Null for stages n8n records |
stage |
captured, classified, resolved, decided, executed, awaiting_human, reconciled, completed |
succeeded |
|
detail |
Optional. Counts, error class. Never content |
at |
Timestamp |
Written as each stage is reached, never batched at the end
A trace that only lands on success tells you nothing about failures, which is the one case it exists for.
Paired with work_item.current_stage, which is the same value denormalised. The trace answers historical questions, and the denormalised column answers the live one.
Partition by month. Old partitions detach cleanly to Parquet in AU object storage, and stay queryable with DuckDB on the rare occasions anyone asks (OI-033 is the same pattern, already noted for dashboards). If nobody ever queries an archive, drop the partitions instead and save the effort.
| Query | From |
|---|---|
| How many items are at each stage right now | work_item.current_stage, grouped |
How many passed through classified in the last hour |
stage_trace, counted |
| Where did this item fail | stage_trace, last row |
| Which stage is slow | stage_trace, gaps between rows |
Without the denormalised column, the live funnel needs a per-item max across every row ever written, which degrades as the table grows. Index on (work_item_id, at) for the historical queries.
Stage-level, not step-level. Sub-stage detail (masking, the model call, parsing) belongs in the tracing tool rather than Postgres. The one question that needed step granularity, "did we pay before it crashed", is answered by model_calls instead.
| This table | LangSmith or Langfuse | |
|---|---|---|
| Granularity | Stage | Every step, with payloads |
| Retention | Long | Short |
| Queryable alongside work items | Yes | No |
| Answers | What happened to this item | Why was this call slow |
At 40 emails a day this is a few hundred rows daily, which is nothing for Postgres. Worth a retention policy eventually, since stage traces age out long before approval records do.
approval¶
One per gated action. Append-only, never updated in place (OI-018).
| Column | Notes |
|---|---|
id, work_item_id, decision_id |
|
action_name, action_params |
Exactly what is being approved |
payload_hash |
Of the exact content. If the payload changes, the approval is void |
status |
pending → approved | regenerated | handled_by_human | superseded |
human_response_reason |
Mandatory on regenerated, since it goes into the Draft prompt and is what makes the second attempt different. Free text on the others |
corrected_category |
Orthogonal to status. Set when a human said the classification was wrong and supplied the right one, whatever outcome they chose. Optional, and a labelled example for the eval set when present |
decided_by, decided_at |
|
final_payload_hash |
In review_app mode, the human's edited text re-hashed. This is what was sent |
This table is the evidence behind "no review, no go", and it carries a retention floor no client can request below (ADR-0026 §6).
There is no expires_at. Approvals do not expire: ageing handles "nobody has looked at it" and draft_staleness handles "the draft no longer fits". Nothing auto-approves.
Superseded records are kept, never deleted. When a draft is regenerated, whether by a human asking or by staleness, the old record moves to superseded and a new pending one is written. Only one is ever pending, so "which is live" is unambiguous.
Keeping the old one matters because the sequence is the evidence: what we proposed, why it was rejected, and what we proposed instead. Deleting it would leave a chain showing only the version that happened to be accepted.
mask_map¶
One row per masked value per work item. Shared by every model call on that item.
| Column | Notes |
|---|---|
id, work_item_id |
|
token |
[PERSON_1], [ORG_1], and so on. Unique within the work item |
value |
The real string |
kind |
person, org, email, phone, address, account |
first_seen_turn |
Which turn introduced it |
Numbering is stable across the whole work item, not per call
Classify and Draft are separate model calls, and they share one mapping.
If Sarah is [PERSON_1] at turn one and [PERSON_2] at turn three, the
model sees two different people in what it believes is one conversation, and
the draft addresses the wrong person or hedges oddly.
A token is allocated once, on first sight, and reused for the life of the item.
Tokens, never plausible pseudonyms. If replacement fails on the way out, Dear [PERSON_1] is obviously broken and the approver catches it, whereas Dear Robert reads as a normal email and gets sent to Sarah. Tokens fail loudly, pseudonyms fail silently, and with an approval gate in front, loud failure is what you want.
The map lives in the tenant's database, never in the brain, because the brain is stateless (ADR-0030). It is client data, so it falls under the same residency and backup rules as everything else in that database.
customer and job¶
Canonical, and populated by backfill plus ongoing sync (OI-068).
No source fields. job maps to a portal project and to an AroFlo work order without either vocabulary leaking in, for the same reason the canonical model exists at all.
job |
Notes |
|---|---|
id |
Ours |
source_system, source_id |
Provenance, and the sync key |
reference |
What the customer would quote, such as J-4471 |
customer_id, status, site, opened_at, closed_at |
source_id is where the export-versus-API question bites: if a file export uses different keys from the API, backfilled rows cannot be matched to later updates.
The two planes¶
Control plane, the shared registry. Two writers only, being Ansible at bootstrap and BOS Console edits, with the brain reading it read-only (ADR-0006). Holds the map, not business content: Tenant, Role, Principal, MailboxBinding, Rule.
Tenant data plane, per-tenant Postgres inside the tenant's own container (ADR-0023). Holds business content, every row stamped principal_id, with RLS enforcing cross-employee isolation (ADR-0017). This plane goes with the client on exit (ADR-0012). Holds: Message, Classification, Draft, TriageFeedback, GenerationFeedback, VoiceExample, LearnedRuleCandidate.
Enforced FKs vs logical cross-plane references¶
The distinction the diagram encodes with solid vs dashed edges is load-bearing:
- Solid edges = real foreign keys within one database.
- Dashed edges = logical references that cross the plane boundary.
Message.principal_idandDraft.principal_id→Principal, andClassification.matched_rule_id→Rule, are not enforced DB foreign keys, because the registry and the tenant Postgres are separate databases in separate containers. The brain resolves them at runtime. Modelling them as enforced FKs would misrepresent the isolation architecture.
Consequently the registry's tenant → connection-string resolution is security-critical: a wrong mapping is a cross-tenant leak.
Entities of note¶
Principal— an employee, or a shared mailbox (kind = person | shared). Carriesrole_id,autonomy_level(L1 default), and its mailbox binding. Onboarding = create + assign role (inherits ruleset) + delegated OAuth consent.Rule— polymorphicscope_type(system | vertical | tenant | role | personal | learned) with nullablescope_ref. Lives in the control plane because rules are config. Precedence resolves in that order.Draft— the approval queue is the set ofstatus = pendingrows; there is no separate queue table (DB-first, clean workflow exit).approver_principal_idis a pointer: self-review by default, re-routable to a manager/rota without a schema change.Classification→TriageFeedbacktrains the shared role layer.Draft→GenerationFeedbacktrains only that principal's voice. The two channels are never conflated.VoiceExample— pgvector, per-principal, never pooled across employees.LearnedRuleCandidate— evidence aggregated in the tenant plane; promoted to an activeRulein the registry only on human approval (the "No review, no go" gate; also the insertion point for the curator / SkillOpt per ADR-0019).
Runtime path for one email¶
- A Graph change-notification (one subscription per employee mailbox) fires; the subscription id resolves to
(tenant, principal)via the registry and dispatches into that tenant's pipeline. - Capture pulls the message with the principal's token, composes the effective ruleset (
system → vertical → tenant → role → personal → learned), classifies, writes aprincipal-scoped row, decides promote / notify / ignore. - On promote, Generate retrieves only that principal's
VoiceExamples, drafts viallm-client, writes theDraftimmediately (DB-first), statuspending. - Gate: the principal's
autonomy_leveldecides — L1 → queue; higher → auto-send only if the client lifted that gate, with the authorisation recorded. Workflow terminates cleanly; the pending row is the queue. - Feedback on approve/edit splits: triage signal → role layer; edit-diff → that principal's voice corpus. Retrieval, not fine-tuning.
Open items¶
Tracked in the register, not here:
- OI-071: this entity model predates the work item and needs reconciling with ADR-0032.
- OI-070: threading must use RFC headers, not
conversationId. - RLS session-principal mechanics: how the brain sets the Postgres session context per invocation so RLS binds. Now interacts with the per-tenant database role in ADR-0032 §5.
- Graph subscription lifecycle: renewal, and revocation on offboarding.
- ~~Confirm tenant compose layout~~ Resolved by ADR-0023: the tenant's n8n runs in the same Compose stack on the tenant's private network, with its own Postgres separate from the business Postgres.