How does an admin onboard users and control what they can access — without developer intervention?
Intent Contract
| Dimension | Statement |
|---|
| Objective | Every entity in the platform has CRUD permissions that an admin manages through the governance UI — no migrations, no developer tickets |
| Outcomes | 1. 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 Metrics | Owner dashboard access stays working. Existing CRM CRUD for contacts and deals must not degrade. Auth redirect loop must not return |
| Constraints | Hard: 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 |
| Autonomy | Allowed: 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 Rules | Complete when: admin can invite a user, assign a role, and that user can access company CRUD. Halt when: auth regression blocks owner access |
| Counter-metrics | Auth error rate must not increase. Page load time for governance pages must stay under 2s. Existing role assignments must survive migration |
| Blast Radius | All 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) |
| Rollback | Reference 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
| # | Intention | Trigger | Observable Success | Forbidden Outcome | Evidence Type | Escalation |
|---|
| S1 | Admin invites a team member | Admin clicks Invite on /settings/governance/invitations | Invited user receives token, accepts, appears in member list with assigned role | User gets Admin role by default; invitation page crashes; user sees other org's data | e2e | — |
| S2 | Admin controls what each role can access per entity | Admin visits /settings/governance/roles and selects a role | Admin sees all data entities with CRUD permission toggles; changes persist immediately | Permission change requires DB migration or developer intervention; admin can remove their own Admin role | e2e | Escalate: changing permission model structure |
| S3 | New data entity automatically gets permission rows | Developer inserts row into governance_resource_types reference table | Seed function creates CRUD permissions for all roles; governance UI shows new entity | Entity exists in schema but no one can access it; seed function silently skips new entity; adding a new entity requires a PostgreSQL migration | integration | — |
| S4 | User with Member role accesses company CRUD | Member navigates to /crm/companies | Member can list, view detail, create, and edit companies | assertPermission fails because company not in reference table; member can delete (should be soft-delete only for Member) | e2e | — |
| S5 | All three roles are seeded on deploy | Application deploys to fresh or reset database | Admin (manage all), Member (CRUD on CRM entities), Viewer (read all) exist with correct permission counts | Only 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)
| # | FeatureID | Function | Artifact | Success Test | Safety Test | Regression Test | Value | State |
|---|
| 1 | AUTHZ-003 | Replace governance_resource_type pgEnum with governance_resource_types reference table; seed company, prospect, question | Migration: create reference table, backfill from enum, alter FK columns to varchar, drop enum | Reference table contains all resource types; company/prospect queryable; future types via INSERT | Existing permission rows unchanged; audit trail intact; all 272 assertPermission calls still resolve | All existing permission rows still resolve | New entities via INSERT, not migration; eliminates 3-way drift | Gap |
| 2 | AUTHZ-003 | Seed CRUD permissions for every entity in governance_resource_types | Updated seed-governance-defaults.ts | All entities have create/read/update/delete permissions for all 3 roles; count matches entities x actions x roles | Seed never duplicates rows on re-run (idempotent); seed never removes existing custom permissions | Existing contact/deal permissions unchanged | Every entity accessible by design, not by accident | Gap |
| 3 | AUTHZ-001 | Seed Admin, Member, Viewer roles on deploy | Updated seed-governance-defaults.ts | 3 roles exist after fresh deploy with correct permission sets | Seed never overwrites customised role permissions | Owner's Admin role assignment survives re-seed | DB reset no longer loses roles | Broken |
Job 2: Governance UI (AUTHZ-003, USER-003)
| # | FeatureID | Function | Artifact | Success Test | Safety Test | Regression Test | Value | State |
|---|
| 4 | AUTHZ-003 | Admin views and edits role permissions per entity | Governance roles page with CRUD toggles per entity | Admin toggles company:read off for Viewer, Viewer cannot access /crm/companies | Admin cannot remove their own Admin role; UI never shows stale state after save | Existing governance pages (/settings/governance/users, /audit) still render | Admin manages permissions without developer | Gap |
| 5 | USER-003 | Fix invitations page crash | Debugged /settings/governance/invitations page | Page renders invite form, lists pending invitations, admin can send invite | Inviting a user to a non-existent org does not create orphan records | Member management page (/settings/governance/users) still works | Admin can onboard team members | Broken |
| 6 | USER-003 | Invitation acceptance assigns correct role | /invite/[token] acceptance flow | Invited user accepts, gets assigned role, lands in dashboard with permissions | Expired or already-used token shows clear error, not crash; user never gets higher role than invited | Owner's existing access unchanged after new user joins | Multi-user orgs become testable | Partial |
Job 3: Entity Permission Verification (AUTHZ-001, USER-002)
| # | FeatureID | Function | Artifact | Success Test | Safety Test | Regression Test | Value | State |
|---|
| 7 | AUTHZ-001 | Company CRUD respects role permissions | Company server actions check assertPermission with correct resource type | Member can list, view, create, edit companies; Viewer can only list and view | Member cannot hard-delete (soft-delete only); Viewer cannot create or edit | Contact and deal CRUD still works with existing permissions | CRM PRD unblocked — company feature usable | Gap |
| 8 | USER-002 | Cross-tenant isolation verified | Integration tests for multi-org queries | Org1 user query returns zero Org2 records | Query without organisationId filter never executes; no data leakage in error messages | Single-org queries return same results as before | Multi-tenancy is proven, not assumed | Not 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
| Dimension | Spec |
|---|
| Route | /settings/governance/roles |
| Entry from | Settings sidebar "Roles & Permissions" link, or Team Members "customize role" link |
| Success | Same page + toast "Permission updated" after toggle |
| Error | Same page + revert toggle to previous state + toast "Permission update failed" |
| Auth denied | Redirect to /dashboard (non-admin never sees governance pages) |
| Loading | Skeleton grid (rows x 4 columns) while listRolePermissionsAction() loads |
| Empty | New role with zero permissions: all toggles OFF, banner "This role has no permissions" |
| Disabled | Admin 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
| Element | Selector | States |
|---|
| Page heading | role="heading", name="Roles & Permissions" | — |
| Role tab (per role) | role="tab", name="{roleName}" | selected, unselected |
| Permission toggle | testid="toggle-{resourceType}-{action}" | on, off, disabled (self-protection) |
| User count badge | testid="role-user-count" | "{N} users have this role", "(no users)" |
| Reset to Default | role="button", name="Reset to Default" | enabled, disabled (no changes) |
| Self-protection tooltip | role="tooltip" | visible (Admin viewing Admin role) |
| Success toast | role="status" | "Permission updated" |
| Error toast | role="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
| Dimension | Spec |
|---|
| Route | /settings/governance/invitations |
| Entry from | Settings sidebar "Invitations" link, or Team Members "Invite" button |
| Success | Same page + toast "Invitation sent to {email}" + new row in pending list |
| Error | Same page + toast "Failed to send invitation" + form retains values |
| Auth denied | Redirect to /dashboard |
| Loading | Spinner on Send button while sendInvitationAction() runs |
| Empty | "No invitations sent yet. Invite your first team member above." |
| Disabled | Send 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
| Element | Selector | States |
|---|
| Page heading | role="heading", name="Invite Team Members" | — |
| Email input | label="Email" | empty, filled, error (invalid) |
| Role dropdown | label="Role" | Member (default), Admin, Viewer |
| Send button | role="button", name="Send Invite" | enabled, disabled, loading |
| Pending invite row | testid="invite-{email}" | pending |
| Revoke button | role="button", name="Revoke" | enabled, disabled |
| Accepted invite row | testid="accepted-{email}" | — |
| Duplicate toast | role="status" | "Invitation already pending for this email" |
| Success toast | role="status" | "Invitation sent to {email}" |
Screen: Accept Invite (/invite/[token])
Serves: Build Contract #6
Flow and States
| Dimension | Spec |
|---|
| Route | /invite/[token] |
| Entry from | Email link (external — user clicks invite URL) |
| Success | Redirect to /dashboard + toast "Welcome to {orgName}" |
| Error | Same page + "This invitation has expired or was revoked" + link to request new invite |
| Auth denied | Redirect to sign-in, then back to /invite/[token] after auth |
| Loading | Spinner while acceptInvitationAction(token) runs |
| Empty | N/A — page requires a token parameter |
| Disabled | Accept button disabled while request inflight |
Elements
| Element | Selector | States |
|---|
| Org name | testid="invite-org-name" | — |
| Assigned role | testid="invite-role" | — |
| Accept button | role="button", name="Accept" | enabled, disabled, loading |
| Expired message | role="alert" | visible (invalid/expired token) |
| Request new link | role="link", name="Request new invite" | — |
Screen: Team Members (/settings/governance/users)
Serves: S1 (verification), Build Contract #4
Flow and States
| Dimension | Spec |
|---|
| Route | /settings/governance/users |
| Entry from | Settings sidebar "Users" link (default governance landing) |
| Success | Same page + toast "Role updated" after role change |
| Error | Same page + toast "Failed to update role" + revert dropdown |
| Auth denied | Redirect to /dashboard |
| Loading | Skeleton table while listUserRolesAction() loads |
| Empty | Single admin row + "Invite team members" link below table |
| Disabled | Current 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
| Element | Selector | States |
|---|
| Page heading | role="heading", name="Team Members" | — |
| Search input | label="Search" | empty, filtering |
| Member row | testid="member-{agentProfileId}" | — |
| Current user badge | testid="current-user-badge" | "(you)" visible |
| Action menu | role="button", name="Actions" | visible (other users), hidden (self) |
| Change Role option | role="menuitem", name="Change Role" | — |
| Role dropdown | testid="role-select-{agentProfileId}" | Admin, Member, Viewer |
| Remove option | role="menuitem", name="Remove from Org" | enabled, hidden (last admin) |
| Confirm dialog | role="alertdialog" | "Remove X from this organisation?" |
| Success toast | role="status" | "Role updated" |
| Invite link | role="link", name="Invite team members" | visible (single user only) |
Navigation
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
| Element | Detail |
|---|
| Situation | Admin has a working platform but cannot onboard team members or control what they access |
| Intention | Any admin can invite users, assign roles, and manage per-entity CRUD permissions through the UI |
| Obstacle | Resource 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 Thing | Reference 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
| Constraint | Rationale |
|---|
Permissions derived from governance_resource_types reference table | Single source of truth — INSERT a row, seed creates CRUD permissions for all roles automatically |
| CRUD = create, read, update, soft-delete | Hard delete is admin-only or system-only; soft-delete preserves audit trail |
| Seed function is idempotent | Must survive re-runs on deploy without duplicating or overwriting |
| Governance UI is admin-only | Non-admin users never see permission management pages |
Architecture Decision: Reference Table over pgEnum
| Dimension | Detail |
|---|
| Decision | Replace governance_resource_type pgEnum with governance_resource_types reference table using varchar PK and FK |
| Status | Decided 2026-03-10 |
| Context | pgEnum 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 |
| Decision | Reference 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 |
| Consequence | New entities via INSERT, not migration. Zero changes to PolicyEngine adapter or any of 272 server action assertPermission call sites |
| Pattern | Google 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:
| Pattern | Criteria | Examples |
|---|
| pgEnum (keep) | Set changes require a design decision | actions, effects, scopes, status |
| Reference table (migrate) | Set grows with application surface area | resource types |
Priority Score
PRIORITY = Pain x Demand x Edge x Trend x Conversion
| Dimension | Score (1-5) | Evidence |
|---|
| Pain | 5 | Admin cannot onboard users; company CRUD blocked; CRM PRD stalled |
| Demand | 3 | Table stakes — every multi-user SaaS needs this |
| Edge | 2 | Commodity infrastructure, 80% already built |
| Trend | 3 | Stable B2B market, not changing |
| Conversion | 4 | Directly unblocks CRM revenue path |
| Composite | 360 | 5 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.
Current State
| Component | Built | Wired | Working |
|---|
| Clerk authentication | Yes | Yes | Yes |
| Identity (agent profiles) | Yes | Yes | Yes |
| Multi-tenancy schema | Yes | Yes | Yes |
| Role/permission tables | Yes | Partial | Partial — only Admin role seeded |
| PolicyEngine + assertPermission | Yes | Yes | Yes — but fails on missing resource types |
| Governance UI (users, roles, audit) | Yes | Yes | Partial — invitations crashing |
| Governance UI (permission toggles) | Yes | No | No — PermissionToggleGrid component exists, not wired to page |
| Seed function | Yes | Partial | Partial — 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
| Sprint | Features | What | Effort | Acceptance |
|---|
| 0 | #1, #2, #3 | Permission infrastructure: reference table migration + seed | 1 day | 3 roles, all entities have CRUD permissions, future types via INSERT |
| 1 | #4, #5, #6 | Governance UI: wire PermissionToggleGrid to /roles page, fix invitations crash, wire acceptance flow | 2 days | Admin toggles permissions in browser (T1-T2), invites work (T3-T5) |
| 2 | #7, #8 | Entity verification: company CRUD + cross-tenant | 1 day | Company 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:
| # | Feature | Test File | Assertion |
|---|
| 1 | governance_resource_types includes company | reference table query | company row exists in reference table with is_active = true |
| 2 | Seed creates 3 roles | seed integration test | Admin + Member + Viewer exist with correct permission counts |
| 3 | assertPermission('read', 'company') succeeds for Member | permission integration test | PolicyEngine returns allow for Member + company:read |
| 4 | Invitations page renders | e2e test | /settings/governance/invitations loads without error |
| 5 | Cross-tenant isolation | integration test | Org1 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.
| Element | Detail |
|---|
| Struggling moment | Admin cannot invite users — invitations page crashes, no self-serve flow |
| Current workaround | Ask developer to manually insert user records and role assignments in database |
| What progress looks like | Admin sends invite email, user accepts, user accesses company CRUD within 5 minutes |
| Hidden objection | "What if I accidentally give someone too much access?" |
| Switch trigger | CRM 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.
| Element | Detail |
|---|
| Struggling moment | Everyone sees everything or nothing — no granularity between Admin and locked-out |
| Current workaround | Don't add users (avoid the problem entirely) |
| What progress looks like | Admin 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 trigger | First customer onboarding — cannot give customer-facing user full Admin access |
Features that serve this job: #1, #4, #7
Role Definitions
| Role | Access | Permissions |
|---|
| Admin | All pages including /settings/governance/* | manage all entities; invite users; assign roles; view audit trail |
| Member | CRM, dashboards, workspace | create/read/update/soft-delete on CRM entities (contact, company, deal, activity, question) |
| Viewer | CRM read-only, dashboards | read on all entities; no create/update/delete |
Relationship to Other PRDs
| PRD | Relationship | Data Flow |
|---|
| Sales CRM | Blocked by this | Company CRUD needs company:* permissions to exist |
| Agent Platform | Blocked by this | Agent registry needs agent:* permissions |
| Sales Dev | Blocked by this | Sales Dev dashboard needs read permissions on CRM entities |
| ETL | Peer | ETL 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?