Skip to main content

Expert Access Model

2026-06-05 update: org_experts and specialist_expert_access were collapsed into a single expert_access table by ADR-007 Stage C (commit 1a904fa5, PR #1711 PR 4). The OrgExpert entity is dropped. expert_access is now the sole Expert↔data grant table. Historic references to org_experts / specialist_expert_access below describe the (former) two-table state — the current schema has a single expert_access table with scope='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_access table, 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_assignments join

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_access table, scope='specialist', specialist_id populated
  • 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 for scope='specialist', NULL for scope='org'
  • expert_id: the Expert being granted access (UUID)
  • revoked_at: NULL for active grants, timestamp for revoked grants
  • granted_by: actor who created the grant
  • granted_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 check
  • canAccessConversation(expertId, conversationId) — per-conversation access check
  • listAccessibleSpecialists(expertId) — returns the UNION of all visible (org × specialist) pairs
  • listAccessibleConversations(expertId, opts) — returns all visible conversations
  • grantSpecialistScope(...) and grantOrgScope(...) — grant management
  • revokeById(id) and revokeAllForExpertInOrg(...) — 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