Skip to main content

Identity & Access Spec

How does an admin onboard users and control what they can access — without developer intervention?

Intent Contract

DimensionStatement
ObjectiveEvery entity in the platform has CRUD permissions that an admin manages through the governance UI — no migrations, no developer tickets
Outcomes1. Admin invites user, user accepts, lands with correct role permissions (within 14 days). 2. Every data entity (contact, company, deal, venture, activity, question) has Create/Read-list/Read-detail/Update/Soft-delete permissions per role. 3. Governance UI shows all entities with permission toggles per role
Health MetricsOwner dashboard access stays working. Existing CRM CRUD for contacts and deals must not degrade. Auth redirect loop must not return
ConstraintsHard: Clerk for authentication (not replaceable). Hard: Single source of truth for resource types via governance_resource_types reference table (INSERT, not migration). Steering: Seed function reads resource types from the reference table, not from hardcoded arrays or enums
AutonomyAllowed: migration generation, seed function refactoring, UI component composition. Escalate: schema changes to governance tables, changes to PolicyEngine defaultAllow. Never: delete existing permissions, change Clerk config, modify production data directly
Stop RulesComplete when: admin can invite a user, assign a role, and that user can access company CRUD. Halt when: auth regression blocks owner access
Counter-metricsAuth error rate must not increase. Page load time for governance pages must stay under 2s. Existing role assignments must survive migration
Blast RadiusAll server actions that call assertPermission. All governance UI pages. Seed function runs on deploy. Migration replaces resourceTypeEnum with reference table (shared by all permission rows)
RollbackReference table rows can be soft-deactivated (is_active = false), not hard-deleted. Seed function is idempotent (safe to re-run). UI changes are purely presentational. Migration rollback: re-create enum from reference table data if needed

Story Contract

#IntentionTriggerObservable SuccessForbidden OutcomeEvidence TypeEscalation
S1Admin invites a team memberAdmin clicks Invite on /settings/governance/invitationsInvited user receives token, accepts, appears in member list with assigned roleUser gets Admin role by default; invitation page crashes; user sees other org's datae2e
S2Admin controls what each role can access per entityAdmin visits /settings/governance/roles and selects a roleAdmin sees all data entities with CRUD permission toggles; changes persist immediatelyPermission change requires DB migration or developer intervention; admin can remove their own Admin rolee2eEscalate: changing permission model structure
S3New data entity automatically gets permission rowsDeveloper inserts row into governance_resource_types reference tableSeed function creates CRUD permissions for all roles; governance UI shows new entityEntity exists in schema but no one can access it; seed function silently skips new entity; adding a new entity requires a PostgreSQL migrationintegration
S4User with Member role accesses company CRUDMember navigates to /crm/companiesMember can list, view detail, create, and edit companiesassertPermission fails because company not in reference table; member can delete (should be soft-delete only for Member)e2e
S5All three roles are seeded on deployApplication deploys to fresh or reset databaseAdmin (manage all), Member (CRUD on CRM entities), Viewer (read all) exist with correct permission countsOnly Admin role exists after DB reset; Editor/Viewer missing (regression from 2026-02-28)integration

Build Contract

Job 1: Permission Infrastructure (AUTHZ-001, AUTHZ-003)

#FeatureIDFunctionArtifactSuccess TestSafety TestRegression TestValueState
1AUTHZ-003Replace governance_resource_type pgEnum with governance_resource_types reference table; seed company, prospect, questionMigration: create reference table, backfill from enum, alter FK columns to varchar, drop enumReference table contains all resource types; company/prospect queryable; future types via INSERTExisting permission rows unchanged; audit trail intact; all 272 assertPermission calls still resolveAll existing permission rows still resolveNew entities via INSERT, not migration; eliminates 3-way driftGap
2AUTHZ-003Seed CRUD permissions for every entity in governance_resource_typesUpdated seed-governance-defaults.tsAll entities have create/read/update/delete permissions for all 3 roles; count matches entities x actions x rolesSeed never duplicates rows on re-run (idempotent); seed never removes existing custom permissionsExisting contact/deal permissions unchangedEvery entity accessible by design, not by accidentGap
3AUTHZ-001Seed Admin, Member, Viewer roles on deployUpdated seed-governance-defaults.ts3 roles exist after fresh deploy with correct permission setsSeed never overwrites customised role permissionsOwner's Admin role assignment survives re-seedDB reset no longer loses rolesBroken

Job 2: Governance UI (AUTHZ-003, USER-003)

#FeatureIDFunctionArtifactSuccess TestSafety TestRegression TestValueState
4AUTHZ-003Admin views and edits role permissions per entityGovernance roles page with CRUD toggles per entityAdmin toggles company:read off for Viewer, Viewer cannot access /crm/companiesAdmin cannot remove their own Admin role; UI never shows stale state after saveExisting governance pages (/settings/governance/users, /audit) still renderAdmin manages permissions without developerGap
5USER-003Fix invitations page crashDebugged /settings/governance/invitations pagePage renders invite form, lists pending invitations, admin can send inviteInviting a user to a non-existent org does not create orphan recordsMember management page (/settings/governance/users) still worksAdmin can onboard team membersBroken
6USER-003Invitation acceptance assigns correct role/invite/[token] acceptance flowInvited user accepts, gets assigned role, lands in dashboard with permissionsExpired or already-used token shows clear error, not crash; user never gets higher role than invitedOwner's existing access unchanged after new user joinsMulti-user orgs become testablePartial

Job 3: Entity Permission Verification (AUTHZ-001, USER-002)

#FeatureIDFunctionArtifactSuccess TestSafety TestRegression TestValueState
7AUTHZ-001Company CRUD respects role permissionsCompany server actions check assertPermission with correct resource typeMember can list, view, create, edit companies; Viewer can only list and viewMember cannot hard-delete (soft-delete only); Viewer cannot create or editContact and deal CRUD still works with existing permissionsCRM PRD unblocked — company feature usableGap
8USER-002Cross-tenant isolation verifiedIntegration tests for multi-org queriesOrg1 user query returns zero Org2 recordsQuery without organisationId filter never executes; no data leakage in error messagesSingle-org queries return same results as beforeMulti-tenancy is proven, not assumedNot verified

Screen Contracts

Four screens, one journey: invite a user, control what they access, verify what happened. Each screen is the atomic execution and test unit.

Screen: Permission Matrix (/settings/governance/roles)

Serves: S2, Build Contract #4

Flow and States

DimensionSpec
Route/settings/governance/roles
Entry fromSettings sidebar "Roles & Permissions" link, or Team Members "customize role" link
SuccessSame page + toast "Permission updated" after toggle
ErrorSame page + revert toggle to previous state + toast "Permission update failed"
Auth deniedRedirect to /dashboard (non-admin never sees governance pages)
LoadingSkeleton grid (rows x 4 columns) while listRolePermissionsAction() loads
EmptyNew role with zero permissions: all toggles OFF, banner "This role has no permissions"
DisabledAdmin viewing Admin role: all toggles disabled with tooltip "Cannot modify own role"
┌─────────────────────────────────────────────────────────┐
│ Roles & Permissions [+ New Role]│
├─────────────────────────────────────────────────────────┤
│ [Admin ▾] [Member ▾] [Viewer ▾] ← role tabs │
├─────────────────────────────────────────────────────────┤
│ Entity Create Read Update Delete │
│ ───────────── ────── ──── ────── ────── │
│ contact ● ● ● ● │
│ company ● ● ● ● │
│ deal ● ● ● ● │
│ venture ● ● ● ○ │
│ activity ● ● ● ○ │
│ question ● ● ● ○ │
│ ● = allowed ○ = denied │
│ [3 users have this role] [Reset to Default]│
└─────────────────────────────────────────────────────────┘

Elements

ElementSelectorStates
Page headingrole="heading", name="Roles & Permissions"
Role tab (per role)role="tab", name="{roleName}"selected, unselected
Permission toggletestid="toggle-{resourceType}-{action}"on, off, disabled (self-protection)
User count badgetestid="role-user-count""{N} users have this role", "(no users)"
Reset to Defaultrole="button", name="Reset to Default"enabled, disabled (no changes)
Self-protection tooltiprole="tooltip"visible (Admin viewing Admin role)
Success toastrole="status""Permission updated"
Error toastrole="status""Permission update failed"

Forbidden states:

  • Admin cannot disable all Read permissions for Admin role (must keep dashboard:read)
  • Toggle for non-existent permission: disabled with "Not yet seeded" tooltip

Screen: Invitations (/settings/governance/invitations)

Serves: S1, Build Contract #5, #6

Flow and States

DimensionSpec
Route/settings/governance/invitations
Entry fromSettings sidebar "Invitations" link, or Team Members "Invite" button
SuccessSame page + toast "Invitation sent to {email}" + new row in pending list
ErrorSame page + toast "Failed to send invitation" + form retains values
Auth deniedRedirect to /dashboard
LoadingSpinner on Send button while sendInvitationAction() runs
Empty"No invitations sent yet. Invite your first team member above."
DisabledSend button disabled while request inflight; Revoke disabled during revocation
┌─────────────────────────────────────────────────────────┐
│ Invite Team Members │
├─────────────────────────────────────────────────────────┤
│ Email: [____________________] │
│ Role: [Member ▾] │
│ [Send Invite] │
├─────────────────────────────────────────────────────────┤
│ Pending Invitations │
│ alice@example.com Member Sent 2h ago [Revoke] │
│ bob@example.com Viewer Sent 1d ago [Revoke] │
│ Accepted │
│ carol@example.com Member Accepted 3d ago │
└─────────────────────────────────────────────────────────┘

Elements

ElementSelectorStates
Page headingrole="heading", name="Invite Team Members"
Email inputlabel="Email"empty, filled, error (invalid)
Role dropdownlabel="Role"Member (default), Admin, Viewer
Send buttonrole="button", name="Send Invite"enabled, disabled, loading
Pending invite rowtestid="invite-{email}"pending
Revoke buttonrole="button", name="Revoke"enabled, disabled
Accepted invite rowtestid="accepted-{email}"
Duplicate toastrole="status""Invitation already pending for this email"
Success toastrole="status""Invitation sent to {email}"

Screen: Accept Invite (/invite/[token])

Serves: Build Contract #6

Flow and States

DimensionSpec
Route/invite/[token]
Entry fromEmail link (external — user clicks invite URL)
SuccessRedirect to /dashboard + toast "Welcome to {orgName}"
ErrorSame page + "This invitation has expired or was revoked" + link to request new invite
Auth deniedRedirect to sign-in, then back to /invite/[token] after auth
LoadingSpinner while acceptInvitationAction(token) runs
EmptyN/A — page requires a token parameter
DisabledAccept button disabled while request inflight

Elements

ElementSelectorStates
Org nametestid="invite-org-name"
Assigned roletestid="invite-role"
Accept buttonrole="button", name="Accept"enabled, disabled, loading
Expired messagerole="alert"visible (invalid/expired token)
Request new linkrole="link", name="Request new invite"

Screen: Team Members (/settings/governance/users)

Serves: S1 (verification), Build Contract #4

Flow and States

DimensionSpec
Route/settings/governance/users
Entry fromSettings sidebar "Users" link (default governance landing)
SuccessSame page + toast "Role updated" after role change
ErrorSame page + toast "Failed to update role" + revert dropdown
Auth deniedRedirect to /dashboard
LoadingSkeleton table while listUserRolesAction() loads
EmptySingle admin row + "Invite team members" link below table
DisabledCurrent user row: no action menu — cannot change own role or remove self
┌─────────────────────────────────────────────────────────┐
│ Team Members │
├─────────────────────────────────────────────────────────┤
│ Search: [____________] │
│ Name Email Role Action │
│ ─────────────── ────────────────── ───── ────── │
│ Matt (you) matt@dream.co Admin │
│ Alice alice@example.com Member [···] │
│ Bob bob@example.com Viewer [···] │
│ [···] → Change Role | Remove from Org │
└─────────────────────────────────────────────────────────┘

Elements

ElementSelectorStates
Page headingrole="heading", name="Team Members"
Search inputlabel="Search"empty, filtering
Member rowtestid="member-{agentProfileId}"
Current user badgetestid="current-user-badge""(you)" visible
Action menurole="button", name="Actions"visible (other users), hidden (self)
Change Role optionrole="menuitem", name="Change Role"
Role dropdowntestid="role-select-{agentProfileId}"Admin, Member, Viewer
Remove optionrole="menuitem", name="Remove from Org"enabled, hidden (last admin)
Confirm dialogrole="alertdialog""Remove X from this organisation?"
Success toastrole="status""Role updated"
Invite linkrole="link", name="Invite team members"visible (single user only)

Settings sidebar: Users | Roles & Permissions | Invitations | Audit Log

Audit trail (/settings/governance/audit): Every toggle, role change, and invitation action logged. Already built — AuditLogTable renders from listAccessAuditAction(). No spec changes needed.

Principles

The Job

ElementDetail
SituationAdmin has a working platform but cannot onboard team members or control what they access
IntentionAny admin can invite users, assign roles, and manage per-entity CRUD permissions through the UI
ObstacleResource type definitions drifted across enum, TS type, and seed arrays (18/21/18/16). Seed function incomplete. Invitations page crashing. No governance UI for permission management
Hardest ThingReference table replaces the enum, so resource types can be added, categorized, and soft-deactivated without migrations. The seed function must derive permissions from the table, not maintain a separate hardcoded list

Design Constraints

ConstraintRationale
Permissions derived from governance_resource_types reference tableSingle source of truth — INSERT a row, seed creates CRUD permissions for all roles automatically
CRUD = create, read, update, soft-deleteHard delete is admin-only or system-only; soft-delete preserves audit trail
Seed function is idempotentMust survive re-runs on deploy without duplicating or overwriting
Governance UI is admin-onlyNon-admin users never see permission management pages

Architecture Decision: Reference Table over pgEnum

DimensionDetail
DecisionReplace governance_resource_type pgEnum with governance_resource_types reference table using varchar PK and FK
StatusDecided 2026-03-10
ContextpgEnum has 18 values, TS union has 21 (company, prospect, question missing from DB), seed array has 18, JSON seed has 16. Three-way drift directly blocks CRM company CRUD
DecisionReference table with code VARCHAR(50) PK. Single const array in domain layer derives TS type AND seeds table. Other enums (action, scope, effect, status) stay — they are stable domain concepts
ConsequenceNew entities via INSERT, not migration. Zero changes to PolicyEngine adapter or any of 272 server action assertPermission call sites
PatternGoogle Zanzibar, SpiceDB, OpenFGA, Keycloak all use schema-driven type definitions, not DB enums. PostgreSQL core developer Tom Lane: "If you need a non-fixed set of key values, use a foreign key instead of an enum"

Entity-Relationship Model:

governance_resource_types (NEW — replaces pgEnum)
code VARCHAR(50) PK ──────────────────────────┐
display_name VARCHAR(100) │
category VARCHAR(30) [rfp|core|crm|marketing|platform]
is_active BOOLEAN │
sort_order INTEGER │

governance_permissions │
id UUID PK │
organisation_id UUID FK │
code VARCHAR(100) "contact:read" │
resource_type VARCHAR(50) FK ──────────────────┘
action ENUM (stays — stable domain concept)
scope ENUM (stays)
status ENUM (stays)


governance_role_permissions (unchanged)
id UUID PK
role_id UUID FK → org_roles
permission_id UUID FK → governance_permissions
effect ENUM (stays)
conditions JSONB

governance_access_audit
resource_type VARCHAR(50) FK ──────────────────┐
action ENUM (stays) │
allowed BOOLEAN │

governance_resource_types ◄──────────────────────┘

Single source of truth pattern:

const RESOURCE_TYPES = [...] as const  (domain layer)

├──→ type ResourceType = typeof RESOURCE_TYPES[number] (TS type)
├──→ seed-governance-defaults reads this array (seed)
└──→ governance_resource_types table seeded from this (DB)

Heuristic — when to use which:

PatternCriteriaExamples
pgEnum (keep)Set changes require a design decisionactions, effects, scopes, status
Reference table (migrate)Set grows with application surface arearesource types

Performance

Priority Score

PRIORITY = Pain x Demand x Edge x Trend x Conversion

DimensionScore (1-5)Evidence
Pain5Admin cannot onboard users; company CRUD blocked; CRM PRD stalled
Demand3Table stakes — every multi-user SaaS needs this
Edge2Commodity infrastructure, 80% already built
Trend3Stable B2B market, not changing
Conversion4Directly unblocks CRM revenue path
Composite3605 x 3 x 2 x 3 x 4

Kill signal: If invitations cannot onboard a second user by 2026-03-24, multi-tenancy is untestable and all downstream PRDs remain blocked.

Platform

Current State

ComponentBuiltWiredWorking
Clerk authenticationYesYesYes
Identity (agent profiles)YesYesYes
Multi-tenancy schemaYesYesYes
Role/permission tablesYesPartialPartial — only Admin role seeded
PolicyEngine + assertPermissionYesYesYes — but fails on missing resource types
Governance UI (users, roles, audit)YesYesPartial — invitations crashing
Governance UI (permission toggles)YesNoNo — PermissionToggleGrid component exists, not wired to page
Seed functionYesPartialPartial — missing entities, missing roles

Build Ratio

~85% composition, ~15% new code. Most work is fixing wiring and adding the permission management UI.

Protocols

Build Order

SprintFeaturesWhatEffortAcceptance
0#1, #2, #3Permission infrastructure: reference table migration + seed1 day3 roles, all entities have CRUD permissions, future types via INSERT
1#4, #5, #6Governance UI: wire PermissionToggleGrid to /roles page, fix invitations crash, wire acceptance flow2 daysAdmin toggles permissions in browser (T1-T2), invites work (T3-T5)
2#7, #8Entity verification: company CRUD + cross-tenant1 dayCompany CRUD works for Member (T6), cross-tenant proven

Agent-Facing Spec

Commands: pnpm test, pnpm typecheck, pnpm build

Boundaries: Always: run tests before commit. Ask first: schema changes to governance tables. Never: modify production data directly, change Clerk configuration.

Test Contract:

#FeatureTest FileAssertion
1governance_resource_types includes companyreference table querycompany row exists in reference table with is_active = true
2Seed creates 3 rolesseed integration testAdmin + Member + Viewer exist with correct permission counts
3assertPermission('read', 'company') succeeds for Memberpermission integration testPolicyEngine returns allow for Member + company:read
4Invitations page renderse2e test/settings/governance/invitations loads without error
5Cross-tenant isolationintegration testOrg1 user query returns zero Org2 records

Players

Demand-Side Jobs

Job 1: Onboard My Team

Situation: Admin has a working platform but needs to add team members who can access CRM data.

ElementDetail
Struggling momentAdmin cannot invite users — invitations page crashes, no self-serve flow
Current workaroundAsk developer to manually insert user records and role assignments in database
What progress looks likeAdmin sends invite email, user accepts, user accesses company CRUD within 5 minutes
Hidden objection"What if I accidentally give someone too much access?"
Switch triggerCRM blocked — can't demo to prospects without company data visible

Features that serve this job: #2, #3, #5, #6

Job 2: Control Access Per Entity

Situation: Admin needs different roles to see different things — sales team sees CRM, viewers see reports only.

ElementDetail
Struggling momentEveryone sees everything or nothing — no granularity between Admin and locked-out
Current workaroundDon't add users (avoid the problem entirely)
What progress looks likeAdmin opens governance UI, sees all entities with CRUD toggles, adjusts per role, changes take effect immediately
Hidden objection"RBAC always takes weeks and then nobody maintains it"
Switch triggerFirst customer onboarding — cannot give customer-facing user full Admin access

Features that serve this job: #1, #4, #7

Role Definitions

RoleAccessPermissions
AdminAll pages including /settings/governance/*manage all entities; invite users; assign roles; view audit trail
MemberCRM, dashboards, workspacecreate/read/update/soft-delete on CRM entities (contact, company, deal, activity, question)
ViewerCRM read-only, dashboardsread on all entities; no create/update/delete

Relationship to Other PRDs

PRDRelationshipData Flow
Sales CRMBlocked by thisCompany CRUD needs company:* permissions to exist
Agent PlatformBlocked by thisAgent registry needs agent:* permissions
Sales DevBlocked by thisSales Dev dashboard needs read permissions on CRM entities
ETLPeerETL pipelines need data import permissions

Context

Questions

If the admin can toggle permissions per entity per role, what happens when two admins toggle the same permission simultaneously?

  • When an admin removes a permission from a role, should existing sessions lose access immediately or on next login?
  • What's the cost when an entity needs non-standard actions beyond CRUD (e.g. approve, export) — does the reference table grow a new column or a new pattern?
  • If the last Admin accidentally locks themselves out of the governance UI, what's the recovery path that doesn't require a developer?
  • What's the difference between "soft-delete" as a permission and "soft-delete" as a data pattern — and should the permission system care?