Skip to main content

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:

  1. Too coarse for some resources. OrgRlsInterceptor only sets app.current_org_id, but Conversation and Message carry 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).
  2. 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 classDefault scopeEnforcementExamples
Customer PII / Conversation / Message / ExpertQueueItemOrg × SpecialistNOT NULL specialist_id required, queries must filter bothApp-layer (RLS interceptor sets both GUCs) + DB CHECK constraints + service-layer query auditconversations, 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 documentsOrg × Specialistspecialist_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 endpointskb_documents, org_documents, Haystack pgvector indexes per ADR-025
Tools / WorkflowsSpecialist-scoped — explicit binding requiredA tool is registered per (specialist_id) in the Manifest; cross-Specialist reuse requires registering against bothtool_specialist_bindings (landed 2026-06-02 via #1328 PR A; runtime binding PR B follows), Manifest tools[] array
Expert visibilitydual-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 auditOrg × Specialistosa_id FK requiredEvery LLM-billable row carries osa_id so billing can GROUP BY osa_idagent_runs, tool_calls, post-merge messages cost columns
Channel credentials & endpointsOrg × 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 NULL clause below reflects the legacy org_documents table behavior. Per ADR-008, kb_documents.specialist_id is NOT NULL — K2 global docs use a sentinel org_id, not a NULL specialist_id. The two tables coexist during the migration window; new KB code should target kb_documents and not rely on NULL specialist_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

  • OrgRlsInterceptor extended to set app.current_specialist_id.
  • conversations.specialist_id and messages.specialist_id migrated to NOT NULL (with backfill).
  • agent_runs.osa_id and tool_calls.osa_id added.
  • kb_documents has specialist_id as NOT NULL per ADR-008; K2 global docs use a sentinel org_id. share_across_specialists fan-out handles cross-Specialist sharing without NULL specialist_id.
  • messages + expert_agent_messages merged with unified cost columns (shipped 2026-05-30, expert_agent_messages table 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

  • OrgSpecialistAssignment joint key (orgId, specialistId) — unchanged.
  • expert_access dual-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