ADR-020 — Isolation Classification Rules
Status: Accepted (shipped 2026-05-29 via PR-A..E in #1148–#1153) Date: 2026-05-29 Authors: @tylerhumanity (audit), @Paul (5-ADR proposal), @Alexander Kalinin (matrix counter-proposal) Decision drivers: 2026-05-29 Slack thread — Tom's domain-model audit and Alex Kalinin's pushback on a binary "Org × Specialist isolation" rule.
Context
Legacy repository guidance described multi-tenancy as "every entity is scoped by org_id". That description is now actively misleading in two opposite directions:
- Too coarse for some resources.
OrgRlsInterceptoronly setsapp.current_org_id, butConversationandMessagecarry PII that absolutely must not flow between Specialists inside the same Org (a Compliance Specialist's KYC conversation should not be visible to a Customer Service Specialist on the same Org). - Too strict for other resources. KB documents are routinely needed cross-Specialist (Finance reads Legal SOPs, KYC reads Compliance history). Forcing Specialist-scoped storage causes document duplication, broken retrieval, and operational drag.
A binary global rule (either "Org-only" or "Org × Specialist everywhere") cannot satisfy both. Isolation must be a matrix keyed on resource class.
Decision
Adopt a per-resource-class isolation matrix. Every persisted entity in the platform falls into one of the rows below.
| Resource class | Default scope | Enforcement | Examples |
|---|---|---|---|
| Customer PII / Conversation / Message / ExpertQueueItem | Org × Specialist — NOT NULL specialist_id required, queries must filter both | App-layer (RLS interceptor sets both GUCs) + DB CHECK constraints + service-layer query audit | conversations, messages, expert_queue_items, specialist_memory (shared memory store, #4426 — team-shared per D1 of the omnichannel-continuity PRD; every service query double-filters, verified by specialist-memory.isolation.spec.ts) |
| KB documents | Org × Specialist — specialist_id required (NOT NULL) per ADR-008. K2 global docs use a sentinel org_id with a real specialist_id. Cross-Specialist sharing via share_across_specialists fan-out, not NULL specialist_id. | App-layer access check via expert_access join (ADR-008); OrgRlsInterceptor sets both GUCs for Specialist-scoped KB endpoints | kb_documents, org_documents, Haystack pgvector indexes per ADR-025 |
| Tools / Workflows | Specialist-scoped — explicit binding required | A tool is registered per (specialist_id) in the Manifest; cross-Specialist reuse requires registering against both | tool_specialist_bindings (landed 2026-06-02 via #1328 PR A; runtime binding PR B follows), Manifest tools[] array |
| Expert visibility | dual-scope (see ADR-007) | expert_access table with scope='org' (whole-Org) or scope='specialist' (one OSA) | expert_access, expert_queue_items filtered through it |
| LLM cost / AgentRun / ToolCall audit | Org × Specialist — osa_id FK required | Every LLM-billable row carries osa_id so billing can GROUP BY osa_id | agent_runs, tool_calls, post-merge messages cost columns |
| Channel credentials & endpoints | Org × Specialist (OSA × Channel) — proposed in ADR-030 (Draft, not yet implemented) | Proposed: UNIQUE(osa_id, channel_type) active; UNIQUE(channel_type, external_id) routes inbound; credentials in Secrets Manager. Current reality: integration_credentials is per-Org (UNIQUE(org_id, integration_type)); Email is the lone per-OSA exception (Gsuite fields on OSA row). osa_channel_endpoints and specialist_channel_personas tables do not exist yet — ADR-030 is a Draft. Slack/WhatsApp/Telegram channel tables exist at Org scope; the proposed OSA-scoped tables do not. | integration_credentials (current), org_specialist_assignments email fields (current), osa_channel_endpoints (proposed — ADR-030 Draft), slack_channel_bindings (current routing helper) |
Rules
1. Default to the matrix; opt out explicitly
Any new table that contains data derived from a Specialist interaction defaults to Org × Specialist scope unless an explicit decision in the entity's docstring justifies a different row. Adding a new entity without a comment indicating its row in this matrix is a code-review block.
2. OrgRlsInterceptor sets two GUCs in Specialist context
SET app.current_org_id = '<uuid>';
SET app.current_specialist_id = '<uuid>'; -- only when request resolves to a Specialist
Triggers for the second SET: channel inbound webhooks (TO address → OSA), AM Setup Wizard step ≥ 2 (post-assignment), OSA detail / edit pages, Specialist-scoped REST endpoints.
The second GUC is not set for endpoints that legitimately operate cross-Specialist within an Org (KB list, Org settings, Expert pool view).
3. Application-layer filters mirror the matrix
Service-layer queries are not allowed to rely on RLS alone. Every find / findOne / createQueryBuilder against a row-1 or row-5 table must explicitly filter both org_id AND specialist_id. PR reviewers reject "only filtering org_id" against PII tables.
4. KB retrieval contract
The Haystack retrieval client (api/src/haystack/) — formerly RagflowRetrievalService pre-ADR-024 — always emits (filter logic preserved across the RAGflow→Haystack cutover; see ADR-024 + ADR-025):
Note: The
OR specialist_id IS NULLclause below reflects the legacyorg_documentstable behavior. Per ADR-008,kb_documents.specialist_idis NOT NULL — K2 global docs use a sentinelorg_id, not a NULLspecialist_id. The two tables coexist during the migration window; new KB code should targetkb_documentsand not rely on NULLspecialist_id.
WHERE org_id = :orgId
AND (specialist_id = :specialistId OR specialist_id IS NULL)
AND status = 'active'
AND (effective_date IS NULL OR effective_date <= now())
A retrieval call without a specialistId argument (e.g. Org-wide search) drops only the OR specialist_id IS NULL clause but keeps the org_id filter. Specialist context can never read another Specialist's private docs.
5. Manifest tools[] is the source of Specialist tool authority
Tools are declared per-Specialist in the Manifest JSON. The runtime resolves enabled tools via tool_specialist_bindings (specialist_id, tool_name). Cross-Specialist tool reuse requires a separate binding row — there is no implicit inheritance.
6. Billing rollup pivots on osa_id
Monthly invoice queries SELECT SUM(cost_cents) FROM messages WHERE osa_id = $1 AND ts >= $month_start. Per-Org rollup is a derived aggregate over OSA rows belonging to the Org, never a primary join key.
Consequences
What changes
OrgRlsInterceptorextended to setapp.current_specialist_id.conversations.specialist_idandmessages.specialist_idmigrated toNOT NULL(with backfill).agent_runs.osa_idandtool_calls.osa_idadded.kb_documentshasspecialist_idas NOT NULL per ADR-008; K2 global docs use a sentinelorg_id.share_across_specialistsfan-out handles cross-Specialist sharing without NULLspecialist_id.messages+expert_agent_messagesmerged with unified cost columns (shipped 2026-05-30,expert_agent_messagestable removed via PR-E #1153).- Manifest schema gains
tools[]. - This ADR became the maintained isolation source of truth; the removed legacy guidance is retained in Git history.
What stays
OrgSpecialistAssignmentjoint key(orgId, specialistId)— unchanged.expert_accessdual-scope from ADR-007 — already matches row 4, no change.- The OSA Gsuite work shipped in ADR-0002 Phase 2 — already an instance of the matrix.
Migration risk
The matrix exposes that today's OrgRlsInterceptor-only filter on Conversation/Message is a live PII leak vector between Specialists in the same Org. The Layer 1 sub-issues (#1117, #1118) close this; until they merge, code review must manually enforce specialist_id filtering on every Conversation/Message query.
Alternatives considered
A. "Org × Specialist everywhere" (Tom's original audit position)
Rejected because Alex Kalinin's pushback is concrete: Finance/Compliance/Legal cross-Specialist KB queries are real business workflows. Forcing Specialist-scoped storage on KB causes document duplication + retrieval ETL.
B. "Org-only everywhere" (status quo)
Rejected because it is a live PII leak vector for Conversation/Message and there is no path forward for per-Specialist billing or per-Specialist cost audit.
C. "Defer the decision, write 5 sequential ADRs" (Paul's initial proposal)
Adopted partially — split into per-class swimlanes (this ADR plus the implementation sub-issues #1118–#1125) rather than 5 sequential ADRs, which lets independent work parallelize.
References
- Slack thread 2026-05-29 (
#engineering— Tom audit / Paul reply / Alex Kalinin counter-proposal) - ADR-007 — Expert Access Scope (Org / Specialist dual-scope precedent)
- ADR-0002 Phase 2 — Specialist email migration to OSA (first real Org × Specialist isolation work)
- Epic #1116 — implementation sub-issues