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β
The codebase has historically described its multi-tenancy as "every entity is scoped by org_id" (see CLAUDE.md prior to this ADR). 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-wide default, optional Specialist tag | specialist_id NULL = Org-wide, value = Specialist-scoped. Retrieval: WHERE org_id = $1 AND (specialist_id = $2 OR specialist_id IS NULL) | org_documents, kb_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) β see ADR-030 | UNIQUE(osa_id, channel_type) active; UNIQUE(channel_type, external_id) routes inbound to one endpoint; credentials stored in Secrets Manager keyed by endpoint; ChannelDispatcher refuses dispatch without an active endpoint; webhook router rejects unknown endpoint pairs with 404 | osa_channel_endpoints (planned), ADR-0002 Phase 2 Gsuite fields (post-migration), retire slack_channel_bindings, deprecate integration_credentials.is_primary fallback |
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):
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.org_documents(or newkb_documents) gainsspecialist_id NULL+ governance columns.messages+expert_agent_messagesmerged with unified cost columns (shipped 2026-05-30,expert_agent_messagestable removed via PR-E #1153).- Manifest schema gains
tools[]. - CLAUDE.md "Multi-Tenancy" section rewritten to reference this ADR.
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