ADR-007: Expert access scope β dual-scope grants (per-Specialist + per-Org)
2026-06-05 status: Stage C/D shipped.
OrgExpertentity +org_expertstable DROPPED via #1711 PR 4 (commit1a904fa5).expert_accessis 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 A | Relation | Entity B | Storage |
|---|---|---|---|
| Client (Org) | 1 β N | Specialists | org_specialist_assignments |
| Expert | N β N | Specialists or Orgs | expert_access (#537) β discriminated by scope |
| Specialist | 1 β 1 | Agent runtime (per Org) | per-(Org Γ Specialist) runtime (#362, #401) |
| Agent runtime | 1 β 1 | Specialist | reverse of above |
| Conversation | N β 1 | Specialist | conversations.specialist_id (immutable β #363 PR-3) |
| Conversation | N β 1 | Org | conversations.org_id (immutable β #363 PR-3) |
| Conversation | N β 0..1 | Expert (current assignment) | conversations.assigned_expert_id (mutable, single) |
| Conversation | 1 β N | Assignment history | conversation_expert_assignments (#363 PR-1) |
| KB doc (K1) | N β 1 | (Org Γ Specialist) | TBD β #541 |
| KB doc (K2) | N β 1 | Specialist (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_idMUST be non-null.scope = 'org'β grant covers the entire Org (every Specialist assigned to it, now and in the future).specialist_idMUST 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:
| Shape | Verdict |
|---|---|
A. Nullable specialist_id β NULL β org-scope | Rejected. 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_access | Rejected. 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β
| Surface | Who can read? | Gate |
|---|---|---|
/client/* (any) | org_admin, org_member, client_admin, client_member of that Org | JWT + Org membership |
/client/conversations/:id | same | Conversation in Expert's Org. No Expert PII in payload (#363 PR-4) |
/workspace/queue | Experts | ExpertAccessService.listAccessibleConversations(expertId) |
/workspace/conversations/:id | Experts | canAccessConversation(expertId, conversationId) |
/workspace/conversations/:id/assignment-history | Experts | same + internal-role gate (#363 PR-1) |
/admin/specialists/:specialistId/business-context (Expert read) | Experts | canAccessSpecialist(expertId, orgId, specialistId) |
/admin/specialists/:specialistId/experts (grant management) | AM (for their Orgs), SuperAdmin | platform role guard + Org ownership |
/admin/orgs/:orgId/experts (org-scope grant management) | AM (for their Orgs), SuperAdmin | platform role guard + Org ownership |
/ops/clients/* | AM, SuperAdmin | platform role |
/admin/specialists/* (write β create / update / delete) | SuperAdmin only | platform role |
/kb/* (future, K1) | Experts | canAccessSpecialist(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β
| Field | Lifecycle |
|---|---|
conversations.org_id | Set on create. Immutable thereafter. Triggered DB-side in #363 PR-3. |
conversations.specialist_id | Nullable 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_at | Immutable 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 viaorg:{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_expertsis 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_accessgrant of either scope."
What livesβ
org_specialist_assignmentsis unchanged. It's the org buys specialist fact.expert_accessis the expert can see this org (or org Γ specialist) fact. Different verbs, both load-bearing.conversations.org_idandconversations.specialist_idare unchanged in shape. They gain DB-level immutability triggers (#363 PR-3).
Forward-compatibility with KB (#541)β
- K1 per-(Org Γ Specialist) docs gate via
canAccessSpecialistdirectly β 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)ORscope='org'grant on an Org that hasspecialistIdassigned sees the global library). - K3 per-Org cross-Specialist docs can be modeled either as K1-duplicated or via a dedicated
org_resource_accessgrant 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)β
- 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).
- Reuse
org_expertsand add aspecialist_idcolumn. Rejected: confuses the semantics of an existing M:N table, breaks every existing query that joins onorg_experts, and prevents soft-deletion / role hints. - Re-introduce expert pools. Rejected per the 2026-05-03 decision; direct assignment is simpler and proven.
- Use the existing
org_specialist_assignmentstable 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. - Use
conversations.assigned_expert_idas 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. - Nullable
specialist_idwithout ascopediscriminator (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. Thescopediscriminator makes the model self-documenting. - 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.
- 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.tsExpert 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_expertsafter #537 soak - #999 β route AM-driven Expert removal through
ExpertAccessService.revokeAccess()(replaces any directorg_expertsdelete). 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.
| Concept | Correct | Never use |
|---|---|---|
| AI persona assigned to a client | Specialist | |
| HP human reviewer | Expert | |
| Company using the platform | client (UI/docs) Β· org (code/DB) | |
| HP person managing clients | AM or Account Manager | |
| Platform-wide admin | SuperAdmin | |
| Expert β data grant | expert_access row | assigned_expert_id) |
| Grant scope | scope='specialist' (per Specialist) OR scope='org' (whole Org) | |
| Thread ownership by an Expert (current, transient) | assignment | |
| Per-thread audit row | assignment history | email_audit_log) |
Implementation note (2026-05-22)β
The full decision landed across five stages:
| Stage | PR / Issue | What shipped |
|---|---|---|
| A | #564 (in #537) | expert_access table + entity + migration + ExpertAccessService read helpers |
| B | #537 | UNION 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.