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​

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:

  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 Γ— Specialist β€” NOT 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-wide default, optional Specialist tagspecialist_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 / 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 Γ— Specialist β€” osa_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) β€” see ADR-030UNIQUE(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 404osa_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​

  • 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.
  • org_documents (or new kb_documents) gains specialist_id NULL + governance columns.
  • 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[].
  • CLAUDE.md "Multi-Tenancy" section rewritten to reference this ADR.

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