Skip to main content

ADR-007: Expert access scope β€” dual-scope grants (per-Specialist + per-Org)

2026-06-05 status: Stage C/D shipped. OrgExpert entity + org_experts table DROPPED via #1711 PR 4 (commit 1a904fa5). expert_access is the sole grant table.

Date: 2026-05-22 Status: Accepted Issue: #539 (this ADR); implemented by #537, #538; related to #363, #541 Authors: Eusden, ethumanity Revision history:

  • 2026-05-22 (initial) β€” proposed per-(Org Γ— Specialist) only.
  • 2026-05-22 (revised same day) β€” extended to dual-scope: per-(Org Γ— Specialist) AND per-Org. See "Why dual scopes" below.

Context​

Today an Expert's data access is gated by org_experts(org_id, expert_id) β€” a single row that grants visibility into every thread on that Org. The 2026-05-03 expert-pools removal made this the only Expert ↔ Org primitive in the schema.

This worked when each Org had one Specialist. It does not work alone now:

  • A real client (e.g. Acme Financial) hires multiple Specialists per Org: one Finance Specialist, one KYC Specialist, eventually one Sales Specialist.
  • Each Specialist owns sensitive data the others should not see. KYC compliance docs must not appear to the Finance Expert reviewing finance threads.

But β€” at the same time β€” many real Experts need whole-Org visibility: senior reviewers covering an entire client's portfolio, account leads handling escalations, HP-side admins. The strict per-(Org Γ— Specialist) model would force them into N separate grants per Specialist on the Org, with constant maintenance churn whenever a new Specialist is added.

So we need both:

  • Tight scope (per Specialist) for Experts who shouldn't cross between sensitive data domains.
  • Broad scope (per Org) for Experts whose job is the entire client account.

The visibility key must therefore support both shapes as first-class concepts in the same primitive.

This decision is foundational. It cascades into the assignment model (#363), onboarding UX (#538), socket room layout, and the KB architecture (#541). Documenting it once, here, prevents drift across those tickets.

Decision​

The five-entity mapping (load-bearing)​

Entity ARelationEntity BStorage
Client (Org)1 β†’ NSpecialistsorg_specialist_assignments
ExpertN β†’ NSpecialists or Orgsexpert_access (#537) β€” discriminated by scope
Specialist1 β†’ 1Agent runtime (per Org)per-(Org Γ— Specialist) runtime (#362, #401)
Agent runtime1 β†’ 1Specialistreverse of above
ConversationN β†’ 1Specialistconversations.specialist_id (immutable β€” #363 PR-3)
ConversationN β†’ 1Orgconversations.org_id (immutable β€” #363 PR-3)
ConversationN β†’ 0..1Expert (current assignment)conversations.assigned_expert_id (mutable, single)
Conversation1 β†’ NAssignment historyconversation_expert_assignments (#363 PR-1)
KB doc (K1)N β†’ 1(Org Γ— Specialist)TBD β€” #541
KB doc (K2)N β†’ 1Specialist (global, HP-owned)TBD β€” #541
                β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ Org β”‚
β””β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”˜
β”‚ 1
β”‚ N
β”Œβ”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ org_specialist_ β”‚
β”‚ assignments β”‚
β””β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
β”‚
β”Œβ”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚Specialist│──── 1:1 (per Org)──│ Agent runtimeβ”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

expert_access (#537):
scope='specialist' β‡’ (org_id, specialist_id, expert_id)
scope='org' β‡’ (org_id, NULL, expert_id)
↓
β”Œβ”€β”€β”€β”€β”€β”€β”
β”‚Expertβ”‚
β””β”€β”€β”€β”€β”€β”€β”˜

Conversation (rows in `conversations`):
org_id (immutable)
specialist_id (immutable; null permitted on create only)
assigned_expert_id (mutable, single, written via assignExpert())
↓
β†’ N rows in conversation_expert_assignments (append-only history)

The single ACL primitive​

expert_access (
id, scope ∈ {'specialist', 'org'},
org_id, specialist_id NULLABLE, expert_id,
role (RESERVED - not currently used in UI), granted_by, granted_at, revoked_at NULLABLE, metadata
)

Discriminator-driven semantics:

  • scope = 'specialist' β€” grant covers exactly ONE (org_id, specialist_id) pair. specialist_id MUST be non-null.
  • scope = 'org' β€” grant covers the entire Org (every Specialist assigned to it, now and in the future). specialist_id MUST be null.

Invariants enforced by DB CHECK constraint:

CHECK (
(scope = 'specialist' AND specialist_id IS NOT NULL)
OR
(scope = 'org' AND specialist_id IS NULL)
)

Uniqueness enforced by partial unique indexes (NULL doesn't collide via standard UNIQUE):

UNIQUE INDEX UQ_ea_specialist_scope (org_id, specialist_id, expert_id) WHERE scope = 'specialist'
UNIQUE INDEX UQ_ea_org_scope (org_id, expert_id) WHERE scope = 'org'

The single ACL gate​

A single ExpertAccessService mediates every read/write that scopes by Expert. No surface invents its own ACL. Every helper returns the UNION of active grants across both scopes:

canAccessSpecialist(e, o, s) = true iff ANY active grant matches
(scope='specialist' AND org=o AND specialist=s AND expert=e) OR
(scope='org' AND org=o AND expert=e)

The four read helpers:

canAccessSpecialist(expertId, orgId, specialistId): Promise<boolean>
canAccessConversation(expertId, conversationId): Promise<boolean>
listAccessibleSpecialists(expertId): Promise<{orgId, specialistId}[]>
listAccessibleConversations(expertId, opts?): Promise<Conversation[]>

listAccessibleSpecialists expands org-scope grants into pairs via JOIN against org_specialist_assignments and de-duplicates against any overlapping specialist-scope grants on the same Org.

listAccessibleConversations performs the same UNION in a single inner-join query with DISTINCT to prevent duplicate rows from overlap.

Why dual scopes (and not just nullable specialist_id)​

We considered three shapes:

ShapeVerdict
A. Nullable specialist_id β€” NULL β‡’ org-scopeRejected. Hides semantics in a single nullable column; partial unique indexes become non-obvious; reviewers have to re-derive the invariant every time.
B. Two tables β€” specialist_expert_access + org_expert_accessRejected. Duplicate soft-delete, duplicate helpers, duplicate backfill, two migrations to maintain. Forces every consumer to UNION the two tables explicitly.
C. Scope discriminator (chosen)One table, explicit scope column, CHECK constraint codifies the invariant, partial unique indexes per scope, future-extensible (e.g. could add 'global' for cross-org SuperAdmins).

Access matrix​

SurfaceWho can read?Gate
/client/* (any)org_admin, org_member, client_admin, client_member of that OrgJWT + Org membership
/client/conversations/:idsameConversation in Expert's Org. No Expert PII in payload (#363 PR-4)
/workspace/queueExpertsExpertAccessService.listAccessibleConversations(expertId)
/workspace/conversations/:idExpertscanAccessConversation(expertId, conversationId)
/workspace/conversations/:id/assignment-historyExpertssame + internal-role gate (#363 PR-1)
/admin/specialists/:specialistId/business-context (Expert read)ExpertscanAccessSpecialist(expertId, orgId, specialistId)
/admin/specialists/:specialistId/experts (grant management)AM (for their Orgs), SuperAdminplatform role guard + Org ownership
/admin/orgs/:orgId/experts (org-scope grant management)AM (for their Orgs), SuperAdminplatform role guard + Org ownership
/ops/clients/*AM, SuperAdminplatform role
/admin/specialists/* (write β€” create / update / delete)SuperAdmin onlyplatform role
/kb/* (future, K1)ExpertscanAccessSpecialist(expertId, orgId, specialistId) (#541)
/kb/specialists/:id/* (future, K2 β€” HP-owned persona library)any Expert with a grant on any (*, specialistId)derive from expert_access (#541)

Immutability rules​

FieldLifecycle
conversations.org_idSet on create. Immutable thereafter. Triggered DB-side in #363 PR-3.
conversations.specialist_idNullable on create. Immutable once set (null β†’ uuid allowed, uuid_a β†’ uuid_b forbidden). Triggered DB-side in #363 PR-3.
conversation_expert_assignments.*Append-only. Entire row immutable after insert.
expert_access.id, scope, org_id, specialist_id, expert_id, granted_by, granted_atImmutable after insert. Revoke is soft-delete via revoked_at. Re-granting (or scope change) creates a new row.

Backfill policy: 1-to-1 from org_experts​

When the migration runs, every existing org_experts(org_id, expert_id) row becomes a expert_access(scope='org') row. No flagging, no AM remap required for migration. Legacy semantics preserved exactly: Experts who could see the whole Org before can still see the whole Org.

INSERT INTO expert_access (scope, org_id, specialist_id, expert_id, ...)
SELECT 'org', org_id, NULL, expert_id, ...
FROM org_experts
ON CONFLICT DO NOTHING

AMs can later tighten an Expert's scope via #538 onboarding: revoke the org-scope grant and create one or more scope='specialist' grants in its place. This is opt-in, not opt-out; no org goes dark at migration time.

This replaces the earlier Backfill Policy C (auto-grant single-Specialist Orgs, flag β‰₯2-Specialist Orgs requires_expert_remapping) which was rejected because:

  • Multi-Specialist Orgs would have gone dark at migration time, requiring AM intervention before any Expert could see anything.
  • The "1 Specialist β†’ auto-grant" exception was the only safe case under the per-(Org Γ— Specialist) model; with dual-scope, the broader org-scope grant IS the safe default.

Socket rooms​

Socket rooms shift to per-(Org Γ— Specialist) and per-Org rooms. Each Expert subscribes to N rooms β€” one per active expert_access grant. The grant set IS the subscription set.

  • A specialist-scope grant subscribes the Expert to the org:{orgId}:specialist:{specialistId} room.
  • An org-scope grant subscribes the Expert to the org:{orgId}:* rooms β€” implementation will likely fan-out via org:{orgId} and per-Specialist rooms emit to both (per-Specialist AND the broad org room).

Exact socket-room model is finalized in #537 Stage C.

Consequences​

What dies​

  • org_experts is deprecated when #537 lands; reads remain for one release for back-compat. Removed in #540 after soak.
  • "Visibility = Org membership" is replaced by "Visibility = an active expert_access grant of either scope."

What lives​

  • org_specialist_assignments is unchanged. It's the org buys specialist fact. expert_access is the expert can see this org (or org Γ— specialist) fact. Different verbs, both load-bearing.
  • conversations.org_id and conversations.specialist_id are unchanged in shape. They gain DB-level immutability triggers (#363 PR-3).

Forward-compatibility with KB (#541)​

  • K1 per-(Org Γ— Specialist) docs gate via canAccessSpecialist directly β€” works for both grant scopes.
  • K2 per-Specialist global docs (HP-owned) derive from this primitive (any Expert with a scope='specialist' grant on (*, specialistId) OR scope='org' grant on an Org that has specialistId assigned sees the global library).
  • K3 per-Org cross-Specialist docs can be modeled either as K1-duplicated or via a dedicated org_resource_access grant if a real use case emerges. Explicitly NOT covered by this ADR.

Cost​

  • One join + OR condition added to every Expert-facing read query. Partial indexes on the active-row hot path keep this sub-ms.
  • AM onboarding becomes a choice per Expert: "org-wide" (default for legacy reasons) or "per-Specialist (pick which)" β€” see #538.
  • No org goes dark at migration time. Big simplification vs the rejected Policy C.

Anti-decisions (things explicitly rejected)​

  1. Per-(Org Γ— Specialist) ONLY scope. Rejected after Eusden's same-day refinement: senior Experts and account leads need whole-Org visibility, and forcing N per-Specialist grants would be operational toil with no security benefit (those Experts are supposed to see everything on the Org).
  2. Reuse org_experts and add a specialist_id column. Rejected: confuses the semantics of an existing M:N table, breaks every existing query that joins on org_experts, and prevents soft-deletion / role hints.
  3. Re-introduce expert pools. Rejected per the 2026-05-03 decision; direct assignment is simpler and proven.
  4. Use the existing org_specialist_assignments table as the ACL. Rejected: that table answers "does Org have Specialist?", not "does Expert have access?". Conflating them means revoking an Expert's access would deactivate the Specialist for the Org.
  5. Use conversations.assigned_expert_id as the access fact. Rejected: assignment is a transient claim; visibility is a stable grant. Many Experts on an Org Γ— Specialist; at most one assigned to any given thread.
  6. Nullable specialist_id without a scope discriminator (shape A). Rejected: the column's semantics depend on its nullness, which is hidden from readers and requires re-deriving the invariant on every encounter. The scope discriminator makes the model self-documenting.
  7. Two separate tables (shape B). Rejected: duplicates soft-delete logic, helpers, and backfill code. Every consumer would need to UNION the two tables explicitly. The discriminator pattern (shape C) has one source of truth.
  8. Backfill Policy C (auto-grant single-Specialist Orgs only; flag the rest). Rejected once dual-scope is on the table: the 1-to-1 backfill to scope='org' preserves legacy semantics exactly for every existing Org with no blackout window.

Implementation references​

  • #537 β€” schema + ExpertAccessService + callsite rewires + grant/revoke endpoints (3 stages)
    • Stage A (PR #564): table + entity + migration + read helpers
    • Stage B: rewire expert-queue.service.ts, conversations.listForExpert, assignable-experts dropdown, specialists.controller.ts Expert reads
    • Stage C: socket rooms refactor + grant/revoke HTTP endpoints + cross-Specialist leak regression
  • #538 β€” per-Specialist onboarding UX (with org-scope choice)
  • #363 β€” assignment history table (PR-1 merged in #542) + immutability triggers (PR-3) + client DTO PII redaction (PR-4) + assignExpert() write path (PR-2; blocks on #537)
  • #540 β€” remove org_experts after #537 soak
  • #999 β€” route AM-driven Expert removal through ExpertAccessService.revokeAccess() (replaces any direct org_experts delete). Closes the last legacy callsite identified in the #537 soak.
  • #541 β€” KB architecture spike (validates K1+K2 against this ADR's primitive)
  • #362, #401 β€” per-Specialist agent runtime β€” keyed on (Org Γ— Specialist), aligns with this ADR

Glossary (terminology β€” load-bearing)​

Use these terms exactly. Wrong terminology in code, commits, or PR descriptions is a signal the writer doesn't understand the platform.

ConceptCorrectNever use
AI persona assigned to a clientSpecialistagent, bot, assistant
HP human reviewerExpertspecialist (for humans), agent
Company using the platformclient (UI/docs) Β· org (code/DB)workspace, account, tenant
HP person managing clientsAM or Account Manageraccount rep
Platform-wide adminSuperAdminadmin (ambiguous)
Expert ↔ data grantexpert_access rowassignment (that's assigned_expert_id)
Grant scopescope='specialist' (per Specialist) OR scope='org' (whole Org)tenant scope, role
Thread ownership by an Expert (current, transient)assignmentownership, claim
Per-thread audit rowassignment historyaudit log (that's email_audit_log)

Implementation note (2026-05-22)​

The full decision landed across five stages:

StagePR / IssueWhat shipped
A#564 (in #537)expert_access table + entity + migration + ExpertAccessService read helpers
B#537UNION reads β€” expert_access βˆͺ org_experts during soak period; existing callsites rewired
C#588 (in #537)AM grant/revoke REST endpoints (POST/DELETE /am/orgs/:orgId/experts)
D#598 + #602 (in #538)Onboarding-facing list endpoints + scope-picker UI in AM step-3 wizard
E#540 (deferred)Drop org_experts after soak; clean up UNION reads

Stages A–D are merged as of 2026-05-22. Stage E is deferred pending soak confirmation.

New grant request flow​