Expert Access Model
2026-06-05 update:
org_expertsandspecialist_expert_accesswere collapsed into a singleexpert_accesstable by ADR-007 Stage C (commit1a904fa5, PR #1711 PR 4). TheOrgExpertentity is dropped.expert_accessis now the sole Expert↔data grant table. Historic references toorg_experts/specialist_expert_accessbelow describe the (former) two-table state — the current schema has a singleexpert_accesstable withscope='org' | 'specialist'.
Overview
Expert access in HumanWork uses a dual-scope grant model on a single expert_access table. Resolved visibility is the UNION of org-scope and specialist-scope grants.
The Two Grant Scopes
1. Org-level grants (expert_access with scope='org')
An org-level grant means the Expert sees all Specialists currently assigned to that organization.
- Scope: entire organization
- Grant record:
expert_accesstable,scope='org',specialist_id IS NULL - Effect: Expert can access conversations and data for every Specialist in the org
- Expansion: Automatically expands to include all Specialists via
org_specialist_assignmentsjoin
2. Specialist-level grants (expert_access with scope='specialist')
A specialist-level grant means the Expert sees only that specific Specialist, within the specified org.
- Scope: single (org × specialist) pair
- Grant record:
expert_accesstable,scope='specialist',specialist_idpopulated - Effect: Expert can access conversations and data for only this one Specialist
- Expansion: Direct 1:1 mapping, no expansion
Visibility Resolution Rules
Visibility is resolved as the UNION of both grant types:
canAccessSpecialist(expert, org, specialist) =
(scope='specialist' AND org_id=org AND specialist_id=specialist AND expert_id=expert AND revoked_at IS NULL)
OR
(scope='org' AND org_id=org AND expert_id=expert AND revoked_at IS NULL)
In other words: an Expert can see a Specialist if ANY of these is true:
- They have an active specialist-level grant for that exact (org × specialist) pair, OR
- They have an active org-level grant for that org
Specialist-level grants are additive, not a replacement for org-level grants. Both grant scopes coexist in the same expert_access table and their results are merged.
Examples
Example 1: Org-level grant only
Setup:
- Expert Alice has one org-level grant for Acme Corp
- Acme Corp has 3 Specialists currently assigned: Bob, Carol, Dave
What Alice sees:
- All 3 Specialists: Bob, Carol, Dave
- All conversations for Bob, Carol, and Dave within Acme Corp
- If Acme Corp later adds a 4th Specialist (Eve), Alice automatically sees Eve too
Example 2: Specialist-level grants only
Setup:
- Expert Alice has three specialist-level grants:
- (Acme Corp, Bob)
- (Acme Corp, Carol)
- (BigCo, Frank)
- No org-level grants
What Alice sees:
- At Acme Corp: only Bob and Carol
- At BigCo: only Frank
- If Acme Corp has a 4th Specialist (Dave), Alice does not see Dave (no org-level grant)
- All conversations for Bob (at Acme), Carol (at Acme), and Frank (at BigCo)
Example 3: Both org-level and specialist-level grants
Setup:
- Expert Alice has:
- One org-level grant for Acme Corp
- One specialist-level grant for (BigCo, Frank)
- Acme Corp has Specialists: Bob, Carol, Dave
- BigCo has Specialists: Frank, Grace, Hank
What Alice sees:
- At Acme Corp: all Specialists (Bob, Carol, Dave) — due to org-level grant
- At BigCo: only Frank — due to specialist-level grant
- All conversations for Bob, Carol, Dave (at Acme) and Frank (at BigCo)
- Does not see Grace or Hank at BigCo (no org-level grant for BigCo, and no specialist-level grant for them)
Implementation Notes
Database Schema
Both grant types are stored in a single expert_access table with a discriminator column:
scope:'org'or'specialist'org_id: always populated (UUID)specialist_id: populated forscope='specialist',NULLforscope='org'expert_id: the Expert being granted access (UUID)revoked_at:NULLfor active grants, timestamp for revoked grantsgranted_by: actor who created the grantgranted_at: timestamp of grant creation
Query Pattern
The service layer resolves visibility via a single SQL query using an OR clause:
SELECT 1 FROM expert_access ea
WHERE ea.expert_id = ?
AND ea.org_id = ?
AND ea.revoked_at IS NULL
AND (
(ea.scope = 'specialist' AND ea.specialist_id = ?)
OR ea.scope = 'org'
)
LIMIT 1
This avoids N+1 queries and ensures both grant types are checked in one round-trip.
Service Implementation
See api/src/expert-access/expert-access.service.ts for the full implementation, including:
canAccessSpecialist(expertId, orgId, specialistId)— per-Specialist access checkcanAccessConversation(expertId, conversationId)— per-conversation access checklistAccessibleSpecialists(expertId)— returns the UNION of all visible (org × specialist) pairslistAccessibleConversations(expertId, opts)— returns all visible conversationsgrantSpecialistScope(...)andgrantOrgScope(...)— grant managementrevokeById(id)andrevokeAllForExpertInOrg(...)— revocation
Historical Context
The dual-mapping model was clarified by Eusden on 2026-05-25 in issue #540. Prior to this documentation, there was confusion about whether specialist-level grants replaced org-level grants. This is not the case — both coexist, and visibility is the UNION of both.
Issue #537 was opened based on the incorrect assumption that specialist-scope grants were a strict replacement for org-scope grants, which would have required deleting the org-scope rows. The correct model is that both scopes provide additive access via the single expert_access table.
See Also
- ADR-007 — Expert Access Scope (Org × Specialist)
- Issue #540 — Original clarification discussion
- Issue #537 — Example of the confusion this dual-mapping model resolves