identity — Module Spec

1. Purpose

Identity is Vrida's authorization layer — the source of truth for who every principal is, what they are allowed to do, and every significant action taken under that authority. It owns the full lifecycle of staff users (and non-human actors), tenant membership, role and permission management, session tracking, access governance, machine identity (service accounts + API keys), and AI agent identity.

Identity sits between Supabase Auth and every other module. Supabase Auth owns credentials (passwords, MFA, OAuth tokens, JWT generation, session refresh). Identity owns authZ — the determination of what a successfully-authenticated principal may do, in what tenant, at what scope.

2. Ownership

Owns:

  • All actor records (actor, identity_user, service_account, agent_identity) — every principal that can act in Vrida
  • Tenant membership (tenant_user) and site-level access (user_site_assignment)
  • Role definitions (role), permission catalog (permission), role-permission assignments (role_permission)
  • Permission group bundles (permission_group, permission_group_permission) and role-bundle wiring (role_permission_group)
  • Role templates (role_template, role_template_permission_group)
  • Role assignments (role_assignment) — time-bounded, polymorphic (actor or group)
  • Actor groups (actor_group, actor_group_member)
  • Per-user permission overrides (user_permission_override)
  • Invitations (invitation) — token-based staff onboarding
  • Support access grants (support_access_grant) — time-boxed Vrida-staff tenant access
  • Password policy (password_policy) — global Vrida-wide floor for email/password users
  • SSO configuration (sso_provider) — schema complete; post-v1 implementation
  • SCIM configuration (scim_config) — schema skeleton; post-v1 implementation
  • Session tracking (identity_session)
  • Application-level authorization event log (identity_access_event)
  • SoD rules and violations (sod_rule, sod_rule_permission, sod_violation)
  • Access requests and approval flow (access_request)
  • Machine identity: service_account, api_key
  • AI agent identity: agent_type_catalog, agent_identity, agent_duty_grant (the A5 agent-authority passport, added 2026-07-06)
  • Per-tenant security policy (tenant_security_policy)

Does NOT own:

  • Raw credentials, JWT generation, OAuth tokens, MFA enforcement → Supabase Auth
  • Site/location structure (site_id targets) → multi_loc (identity FKs to it; deferred until Phase 4)
  • Cross-module compliance audit log (audit.audit_log) → audit schema; identity writes identity_access_event as its own event log; the audit module's central log is a separate, hash-chained record that references identity actors
  • End-customer consent → crm schema
  • Org-level legal consent → platform.agreement_acceptance
  • AI agent execution history (per-run logs, token cost) → ai.agent_execution (to be built in the AI module — see DR-24)
  • Tenant provisioning as a business concern → platform

3. Layer & Dependencies

Layer: Foundation / service-layer. Migration: Phase 3 — platform migrates first (Phase 1), then identity.

Depends on:

  • platformplatform.tenant is the FK target for all tenant-scoped identity tables. Platform migrates at Phase 1; identity FKs to it.
  • Supabase Authidentity_user.supabase_auth_user_id is a plain UUID reference to auth.users (not an enforced FK — DR-1). Integrity maintained by application code and Supabase webhooks.

Depended on by:

  • Every module that has user attribution, actor-scoped access control, or audit trail → calls IdentityService for authorization checks
  • platform — closes 8 deferred FK columns at Phase 3 (4 → identity.actor, 4 → identity.identity_user — see Cross-Phase FKs in schema doc and OPEN_ITEMS)
  • auditaudit.audit_log.actor_user_id will be generalized to identity.actor + actor_type column when the audit schema is designed (OPEN_ITEMS)
  • admin, reporting, notifications, rewards — all depend on identity for authorization context

Cross-phase FKs to multi_loc.site — RESOLVED 2026-07-10 for 3 of 5: tenant_user.default_site_id, user_site_assignment.site_id, and invitation_site_assignment.site_id (added 2026-07-10, fix #3) are now real composite FKs → multi_loc.site(id, tenant_id) — Identity's 5th reopen, immediately following the human decision fix #3 flagged. The blocking orphan (one live user_site_assignment row referencing a site_id that never existed) was investigated (isolated dev-seed fixture junk, confirmed via a DB-wide tenant-isolation scan) and deleted first. See §14, §21, and PROJECT_DECISIONS #54. Still genuinely deferred, confirmed still bare by independent verification: user_permission_override.scope_id (when scope_type='site') and access_request.requested_scope_id (when requested_scope_type='site', a previously-untracked 4th sibling surfaced during this pass's own docs update) — neither was part of the wired bundle. See OPEN_ITEMS.

4. Tables

Identity owns 36 tables. Full column-level detail in docs/database/schema_docs/identity.md — names and groupings only here.

Actor root (polymorphic — 5 tables): actor (root identity record for every principal — actor_type CHECK widened 2026-07-09 to add 'operator', see DR-35), identity_user (human-detail; shared PK ← actor), service_account (machine-detail; shared PK ← actor), agent_identity (AI-agent-detail; shared PK ← actor; gained the agent kill-switch — status/suspended_at/suspended_by_actor_id/suspension_reason — in Remediation Phase 3, 2026-07-08, see §8 DR-34), operator (Vrida cross-tenant staff detail; shared PK ← actor; added 2026-07-09, see §9 DR-35).

Operator role assignment (1 table): operator_role_assignment (revocable operator→role grant; NULL-safe self-issue guard; see §9 DR-35).

Tenant membership & site access (2 tables): tenant_user (pure membership join: actor ↔ tenant), user_site_assignment (per-site access with optional site-specific role override; gained a nullable reciprocal-traceability column created_from_invitation_site_assignment_id in the 2026-07-10 Header/Line Remediation reopen, fix #3 — see §14, §21, DR-36).

Role & permission catalog (5 tables): role (mixed-scope: NULL tenant_id = Vrida built-in; populated = tenant custom; requires_approval_for_agents flags the agent-elevation approval gate — schema-level only, DR-30), permission (global catalog of all permission codes), role_permission (role ↔ permission with effect; append-only), permission_group (Vrida-defined bundles of permissions), permission_group_permission (bundle ↔ permission join; append-only).

Role assignment & wiring (4 tables): role_assignment (time-bounded polymorphic assignment: actor or group → role), role_permission_group (tenant-scoped bundle-to-role wiring; soft-delete), role_template (Vrida-seeded starting-point role definitions), role_template_permission_group (template ↔ bundle join; append-only).

Actor groups (2 tables): actor_group (tenant-scoped team/department groups), actor_group_member (actor ↔ group join; soft-delete).

Per-user overrides & invitations (3 tables): user_permission_override (scoped grant or deny overriding role-derived permissions), invitation (token-based staff invite; token_hash only — never plaintext; gained a prerequisite UNIQUE(id, tenant_id) in the 2026-07-10 reopen, and its site_assignments JSONB is now deprecated in place), invitation_site_assignment (NEW 2026-07-10, Header/Line Remediation fix #3 — pre-acceptance staging of intended site access, mirroring user_site_assignment's own shape; see §14, §21, DR-36).

Support access (1 table): support_access_grant (time-boxed Vrida-staff audited access to a tenant).

Policy & configuration (3 tables): password_policy (global Vrida-wide singleton), sso_provider (per-tenant SSO config — post-v1 impl), scim_config (per-tenant SCIM config — schema skeleton, post-v1).

Sessions & event log (2 tables): identity_session (global session record — login-to-logout lifecycle), identity_access_event (append-only authorization event log).

SoD (3 tables): sod_rule (Vrida-defined incompatible-permission rules), sod_rule_permission (rule ↔ permission join; append-only), sod_violation (tenant-scoped detected violation with waiver lifecycle; decision_snapshot jsonb nullable carries the permission-set/role-combination active at detection time — exact key shape deferred, DR-30).

Access governance (1 table): access_request (structured request for a role or permission override; single-approver v1 flow).

Machine identity (2 tables): service_account (already listed above as actor detail), api_key (hashed credential for a service account — key shown once on creation, never stored).

AI agent identity (2 tables): agent_type_catalog (Vrida-defined agent type catalog — reference), agent_duty_grant (the A5 agent-authority passport, added 2026-07-06 — per (agent, permission) authority grant governing draft/execute/needs-approval and spend/quantity ceilings; see §8, DR-31, DR-32, PROJECT_DECISIONS #22). agent_skill and agent_skill_assignment were DROPPED 2026-07-17 (Phase 6 of the agents-v2/v3 build, Identity's 6th reopen) — superseded by agents.skill_definition/skill_version/agent_skill_assignment (built Phase 5); see §8 DR-38 and PROJECT_DECISIONS #67.

5. Capabilities

  • Actor provisioning — create human users, service accounts, and AI agents as first-class actors sharing a single polymorphic actor root; enforce insertion order (actor first, then detail table)
  • Tenant membership — manage tenant_user (join/suspend/remove); enforce human-only invariant via trigger; site-specific access via user_site_assignment
  • Invitation flow — create token-hashed invites; accept (provisions identity_user + tenant_user); expire/revoke
  • Permission resolution — full 5-step algorithm (direct role assignments → group role assignments → per-role expansion [direct + inherited + bundle] → override → deny-beats-allow); detailed in §7
  • Role management — CRUD for custom tenant roles; single-parent inheritance (depth cap 5); bundle wiring; template clone (materializes to role_permission rows at clone time — DR-16)
  • Role assignment — time-bounded (seasonal staff); group-level; polymorphic assignee; append-only history; on-approval from access_request
  • Per-user overrides — scoped grant/deny (tenant, site, module scope); time-bounded; replaces role-derived result for that permission
  • Session management — create identity_session at login; idle-timeout expiry; log-out-all-devices; concurrent-session cap via tenant_security_policy; session_id correlation to identity_access_event
  • Support access — time-boxed support_access_grant; mandatory time-window check; every use logged; expiry sweep
  • SoD detection — hybrid on-change + daily sweep; detect-and-flag (never block); violation lifecycle (open → acknowledged / waived / resolved); waiver time-bounds
  • Access governanceaccess_request single-approver v1 flow; on-approval creates role_assignment or user_permission_override; full lifecycle event trail
  • Machine identity — service-account provisioning + API key issuance (hash-only storage; show-once); api_key rotation policy via tenant_security_policy.api_key_rotation_days
  • Agent identity — first-class actor; agent type catalog; per-instance skills; role assignment same as human path; no mandatory owner; audit-attributed independently
  • Security policy — per-tenant overlay on global password_policy (session timeout, MFA OR-floor, max concurrent sessions, api_key rotation days)
  • Authentication (v1) — email/password + Google OAuth via Supabase Auth; link via supabase_auth_user_id; read password_policy at login
  • SSO/SCIM — schema complete; implementation post-v1 (see §14)

6. Service Contract — IdentityService

The public surface other modules and pages call. No module reads identity tables directly — everything goes through IdentityService. Built 2026-06-29 — 86 public methods across the 5 phases below. All methods receive a caller context (actorId, tenantId) for RLS enforcement and audit attribution. Zero HTTP controllers exist yet (see OPEN_ITEMS) — every method is reachable only via direct service injection today, not over the network.

Actor & User Management

  • provisionUser(supabaseAuthUserId, email, fullName?) — INSERT actor (actor_type='user') first, then identity_user with the same UUID as PK (shared-PK insertion order — DR-11). Returns the identity_user.id (= actor.id). Never called at runtime via client-side code — only via the registration webhook path.
  • getUser(userId) — fetch identity_user + actor row. Access control: caller must share a tenant with the target (via tenant_user) or caller must be a platform user with an active support_access_grant.
  • updateUser(userId, fields) — update display/contact fields on identity_user. Immutable: id, supabase_auth_user_id, email_normalized.
  • deactivateUser(userId, reason?) — set actor.status = 'deactivated'; set identity_user.deleted_at. All active identity_session rows for the actor are revoked. Active role assignments remain (audit history) but are ineffective (actor.status check in resolution).
  • provisionServiceAccount(tenantId, name, description?, actorId) — INSERT actor (actor_type='service_account') then service_account (shared PK). System-generates client_id (vsvc_ prefix). Returns the service_account.id. Writes identity_access_event.
  • provisionAgent(tenantId, agentTypeId, config?, actorId) — INSERT actor (actor_type='agent') then agent_identity (shared PK). Returns agent_identity.id. Only Vrida provisioning service may call this. (Previously also read: "Seeds agent_skill_assignment rows from the type's default skills." identity.agent_skill_assignment was dropped 2026-07-17 — DR-38 — superseded by agents.agent_skill_assignment at skill-version granularity; this method's current default-skill-seeding behavior against the agents module was not in scope of this docs pass and is not confirmed here.)

Tenant Membership

  • joinTenant(actorId, tenantId, invitedByUserId?) — INSERT tenant_user; trigger trg_tenant_user_actor_type_check enforces human-only. Actors with actor_type != 'user' must not be passed here — service layer validates before INSERT; trigger is the second-layer guard (DR-26).
  • suspendMember(tenantId, actorId, callerActorId) — set tenant_user.status = 'suspended'. Writes identity_access_event (role_changed type with metadata).
  • removeMember(tenantId, actorId, callerActorId) — set tenant_user.status = 'removed' + removed_at. Soft-deletes the tenant_user row (deleted_at). Active role assignments remain as history.
  • listTenantMembers(tenantId, statusFilter?) — returns tenant_user rows joined to identity_user + actor. RLS ensures only the caller's tenant is visible.
  • assignSite(tenantUserId, siteId, siteRoleId?, isPrimary?) — INSERT user_site_assignment. site_id is now a real composite FK → multi_loc.site(id, tenant_id) as of 2026-07-10 (user_site_assignment_site_tenant_fkey) — the DB now rejects an orphan or cross-tenant siteId directly; service-layer validation via PlatformService remains as defense-in-depth. See PROJECT_DECISIONS #54.
  • removeSiteAccess(tenantUserId, siteId, callerActorId) — soft-delete user_site_assignment row.

Role Management

  • createCustomRole(tenantId, roleCode, name, description?, parentRoleId?, callerActorId) — INSERT role (role_type='tenant_custom', tenant_id=tenantId). If parentRoleId is set, validate: (a) no cycle — walk ancestor chain; (b) cross-tenant guard — parent.tenant_id IS NULL OR = tenantId. Reject if cycle or cross-tenant violation found. Depth cap 5 (DR-12).
  • cloneFromTemplate(tenantId, templateId, roleCode, name, callerActorId) — read role_template_permission_group for the template → expand each bundle via permission_group_permission → INSERT role (tenant_custom) + all role_permission rows in a single transaction. Cloned role is independent — template changes do not propagate (DR-16).
  • updateRole(roleId, fields, callerActorId) — update role.name, role.description, role.parent_role_id. Parent change triggers same cycle + cross-tenant validation as create.
  • deactivateRole(roleId, callerActorId) — set role.is_active = false. Does not remove existing assignments — active assignments to a deactivated role are ineffective (resolution skips inactive roles).
  • addPermissionToRole(roleId, permissionId, effect, callerActorId) — INSERT role_permission (UNIQUE on role_id, permission_id — duplicate fails). Triggers on-change SoD detection for all active holders of this role.
  • removePermissionFromRole(roleId, permissionId, callerActorId) — DELETE role_permission row (append-only: hard delete, not soft-delete — DR-4). Triggers on-change SoD detection.
  • attachBundle(tenantId, roleId, permissionGroupId, callerActorId) — INSERT role_permission_group (role must have role_type='tenant_custom' — app-enforced). Triggers on-change SoD detection.
  • detachBundle(tenantId, roleId, permissionGroupId, callerActorId) — soft-delete role_permission_group row (DR-17). Triggers on-change SoD detection.

Role Assignment

  • assignRole(tenantId, actorId?, actorGroupId?, roleId, startsAt?, endsAt?, callerActorId, skipApprovalGate?)Agent-approval gate (DR-30, enforced 2026-07-06): if actorId is set and not skipApprovalGate, checks actor.actor_type and role.requires_approval_for_agents; when the assignee is an agent AND the role requires approval, routes through submitAccessRequest() instead of assigning directly and returns {status: 'pending_approval', id: access_request.id}. Otherwise INSERTs role_assignment directly and returns {status: 'assigned', id: role_assignment.id}. Return type is an explicit discriminated object, not a bare string — callers (especially agents) must branch on status since id refers to a different table depending on outcome. Exactly one of actorId/actorGroupId must be non-NULL (mirrors the DB CHECK). startsAt defaults to now(); endsAt NULL = permanent. Sets assigned_by_actor_id = callerActorId. No UNIQUE constraint on (tenant, actor, role) — the same actor may hold the same role across non-overlapping windows (DR-15). Triggers on-change SoD detection if actorId is set and the assignment was made directly. Writes identity_access_event (role_changed) via the direct-assignment path; the gated path writes access_request_submitted instead (via submitAccessRequest()). skipApprovalGate is set ONLY by approveAccessRequest()'s own call site — bypassing the gate there prevents an infinite submit→approve→assignRole→re-submit loop, since that call IS the human-reviewed approval.
  • revokeRoleAssignment(assignmentId, callerActorId) — set role_assignment.status = 'revoked' + revoked_at + revoked_by_actor_id. Only mutable fields on this table. Triggers on-change SoD detection. Writes identity_access_event.
  • listRoleAssignments(tenantId, actorId?, roleId?, statusFilter?) — fetch role_assignment rows. Callers typically filter status='active' AND (ends_at IS NULL OR now() < ends_at) for effective assignments.

Permission Overrides

  • grantOverride(tenantId, tenantUserId, permissionId, effect, scopeType, scopeId?, scopeCode?, startsAt?, endsAt?, reason?, callerActorId) — INSERT user_permission_override. Validates scope consistency (site-scope needs scopeId, module-scope needs scopeCode, tenant-scope needs neither). UNIQUE partial index prevents duplicate active overrides for (tenantUserId, permissionId, scopeType). Writes identity_access_event (permission_override_applied).
  • revokeOverride(overrideId, callerActorId) — soft-delete user_permission_override.
  • listOverrides(tenantId, tenantUserId?) — list all active overrides for a tenant or specific member.

Groups

  • createGroup(tenantId, name, groupType, description?, callerActorId) — INSERT actor_group. Name UNIQUE per tenant (partial index WHERE deleted_at IS NULL).
  • updateGroup(groupId, fields, callerActorId) — update name, description, status. Inactive groups (status='inactive') are excluded from role-assignment resolution.
  • deleteGroup(groupId, callerActorId) — soft-delete actor_group. Does NOT cascade to actor_group_member rows — those are soft-deleted separately or left as-is (they are excluded from resolution because the group is inactive/deleted).
  • addMember(groupId, actorId, callerActorId) — INSERT actor_group_member. Soft-delete pattern — existing soft-deleted row for same (group, actor) does not block a new row.
  • removeMember(groupId, actorId, callerActorId) — soft-delete actor_group_member row.

Invitations

  • sendInvitation(tenantId, email, roleId, siteAssignments?, message?, expiresAt?, callerActorId) — generate a cryptographically random token, store only the token_hash (bcrypt/SHA-256). UNIQUE partial index prevents duplicate pending invites to the same email per tenant. Writes identity_access_event (invitation_sent). Token is returned to the caller for email delivery — never stored again.
  • acceptInvitation(tokenPlaintext, acceptingUserId) — hash the inbound token, look up by token_hash, verify status='pending' AND now() < expires_at. On match: set status='accepted', accepted_at, accepted_by_user_id; INSERT/upsert identity_user (or link if already exists); INSERT tenant_user; INSERT user_site_assignment rows from site_assignments JSONB. Writes identity_access_event (invitation_accepted). Entire operation in one transaction. Not updated this pass (schema-only, DR-36): identity.invitation_site_assignment (added 2026-07-10) is the correctly-modeled, real-row replacement for the site_assignments JSONB this method still reads from — a future IdentityService pass should switch sendInvitation() to write staging rows here and acceptInvitation() to copy them into user_site_assignment (populating the new created_from_invitation_site_assignment_id reciprocal column), retiring the JSONB read entirely. Until then, both the JSONB column and the new table coexist, with the JSONB column deprecated in place (comment only).
  • revokeInvitation(invitationId, callerActorId) — set status='revoked', revoked_at, revoked_by_user_id. Writes identity_access_event (invitation_revoked).
  • expireInvitations() — background sweep: set status='expired' for all rows WHERE status='pending' AND expires_at < now(). Writes identity_access_event (invitation_expired) per row.

Session Management

  • createSession(actorId, supabaseSessionId?, ipAddress?, userAgent?, deviceFingerprint?) — INSERT identity_session. Called at login, after Supabase Auth succeeds. Returns identity_session.id (= session_id for all subsequent event writes in this session). The concurrent-session cap (tenant_security_policy.max_concurrent_sessions) is enforced after tenant selection (when the actor sets a tenant context within the session), not at session creation time — because identity_session has no tenant_id and the actor may belong to multiple tenants. At session creation, no tenant-scoped policy check is run. After the actor selects a tenant: read that tenant's tenant_security_policy.max_concurrent_sessions; if at or above cap, revoke the oldest active session for this actor (UPDATE ended_at, end_reason='all_devices_revoked') before proceeding.
  • endSession(sessionId, endReason, revokedByActorId?) — set identity_session.ended_at = now(), end_reason, optionally revoked_by_actor_id. Writes no identity_access_event (session end is implicit in the session record itself).
  • revokeAllSessions(actorId, callerActorId) — set ended_at = now(), end_reason = 'all_devices_revoked' for all rows WHERE actor_id = actorId AND ended_at IS NULL. Writes identity_access_event.
  • sweepIdleSessions() — background sweep: for each identity_session WHERE ended_at IS NULL AND last_active_at < now() - interval '? minutes', read tenant_security_policy.session_timeout_minutes for the actor's primary tenant (or the platform default service-layer constant if no row); expire sessions past the timeout. Writes ended_at, end_reason='expired'.
  • updateLastActive(sessionId) — UPDATE identity_session.last_active_at = now(). Called on every authenticated request. Hot-path — no event write.

Support Access

  • grantSupportAccess(tenantId, vridaUserId, reason, startsAt, endsAt, grantedByUserId, correlationId?, metadata?) — validates vridaUserId has identity_user.is_platform_user = true. INSERT support_access_grant. Writes identity_access_event (support_access_granted).
  • checkSupportAccess(vridaUserId, tenantId) — SELECT from support_access_grant WHERE vrida_user_id = vridaUserId AND tenant_id = tenantId AND status = 'active' AND now() BETWEEN starts_at AND ends_at. Both conditions required — status alone is not sufficient. Returns the grant row if valid, null if not.
  • recordSupportAccess(grantId, vridaUserId, tenantId, sessionId?) — INSERT identity_access_event (support_access_used). Called on every request made under a support grant.
  • revokeSupportAccess(grantId, revokedByUserId) — set status='revoked', revoked_at, revoked_by_user_id. Writes identity_access_event (support_access_revoked).
  • expireSupportGrants() — background sweep: set status='expired' for all rows WHERE status='active' AND ends_at < now(). No event write on sweep (grant expired naturally — distinguished from explicit revoke by status value).
  • listSupportAccessGrants(tenantId?) — cross-tenant if tenantId omitted, scoped to one tenant otherwise. Joins to identity.operator (support actor's display name — retargeted 2026-07-09 from identity_user, see DR-35) and, when cross-tenant, to platform.tenant (tenant name/slug). Read-only; grant/check/use/revoke above cover the write path, which stays service-layer-only (no write endpoint).

REST API (built 2026-07-08, PROJECT_DECISIONS #41) — IdentityController, identity's first-ever HTTP-reachable route. Before this, IdentityService (86 methods, fully tested) had zero HTTP surface at all — every method was only ever called from within other services (see MODULE_BUILD_STATUS.md footnote ⁵, now stale). The admin-login routes and the 2 support-access read routes are wired; every other IdentityService capability above remains service-layer-only until its own controller is built.

Endpoint Returns
GET /admin/tenants/:id/support-access this tenant's support access grants
GET /admin/support-access support access grants across all tenants
POST /admin/auth/login { actor, sessionId } — called by the admin console right after a successful Supabase signInWithPassword. AdminAuthGuard has already resolved the Bearer JWT against identity.operator and required status='active' by the time this handler runs, so reaching it at all confirms the caller is a real, active Vrida operator (see DR-35). recordLogin(actorId, {ipAddress, userAgent}) creates an identity_session row + identity_access_event (login), and bumps identity.operator.last_login_at.
POST /admin/auth/logout { ok: true }recordLogout(sessionId, actorId) ends the given session (ended_at, end_reason='logout') and writes identity_access_event (logout).

SoD Detection

  • detectSodViolations(tenantId, actorId) — on-change path. Resolve the actor's effective permission set via §7 Permission Resolution, then extract only the ALLOW-effect permissions (discard any deny-effect entries). A denied permission cannot be exercised; counting it toward a toxic combination would produce phantom violations. For each active sod_rule where is_active = true: check if allow_perms ⊇ rule_perms using HAVING COUNT(*) = rule_permission_count. If yes and no valid waiver exists for (tenant, actor, rule): INSERT sod_violation (status='open') OR UPDATE last_detected_at if an open row already exists. Writes identity_access_event (sod_violation_detected) on new INSERT. Detection NEVER blocks the triggering action.
  • dailySodSweep() — iterate all active SoD rules × all tenants; detect violations for all actors whose role or permission set may have changed via role_permission catalog changes. Same detection logic as above.
  • acknowledgeViolation(violationId, callerActorId, note?) — set sod_violation.status = 'acknowledged', acknowledged_by_actor_id, acknowledged_at. Writes identity_access_event (sod_violation_acknowledged).
  • waiveViolation(violationId, callerActorId, waiverReason, waiverExpiresAt?) — set status='waived', waiver_reason, waiver_expires_at. Writes identity_access_event (sod_violation_waived). Suppresses re-detection while active.
  • resolveViolation(violationId, callerActorId, resolutionNote?) — set status='resolved', resolved_by_actor_id, resolved_at. Writes identity_access_event (sod_violation_resolved).

Access Requests

  • submitAccessRequest(tenantId, requesterActorId, requestType, requestedRoleId?, requestedPermissionId?, requestedScopeType?, requestedScopeId?, justification, approverActorId?, expiresAt?, startsAt?, endsAt?) — INSERT access_request (status='pending'). Validates request_type consistency CHECK (role request needs requestedRoleId; permission_override needs requestedPermissionId). Writes identity_access_event (access_request_submitted).
  • approveRequest(requestId, reviewerActorId, reviewNote?) — set status='approved', reviewed_by_actor_id, reviewed_at. Then: if requestType='role' → assignRole(..., skipApprovalGate: true) creating role_assignment (the gate is bypassed here — this call is itself the human review the gate exists to enforce, and re-entering it would loop back into submitAccessRequest()); if requestType='permission_override' → grantOverride(...) creating user_permission_override. Writes identity_access_event (access_request_approved).
  • denyRequest(requestId, reviewerActorId, reviewNote?) — set status='denied', reviewed_by_actor_id, reviewed_at. Writes identity_access_event (access_request_denied).
  • withdrawRequest(requestId, requesterActorId) — set status='withdrawn'. Only the original requester may withdraw. Writes identity_access_event (access_request_withdrawn).
  • expireAccessRequests() — background sweep: set status='expired' for rows WHERE status='pending' AND expires_at IS NOT NULL AND now() >= expires_at. Writes identity_access_event (access_request_expired) per row.

Security Policy

  • getSecurityPolicy(tenantId) — SELECT tenant_security_policy WHERE tenant_id = tenantId. Returns null if no row exists (tenant inherits platform defaults). Always joined with password_policy (the active row) — callers that need effective MFA requirement must read both (DR-28).
  • upsertSecurityPolicy(tenantId, fields, callerActorId) — INSERT … ON CONFLICT (tenant_id) DO UPDATE. Fields: session_timeout_minutes, mfa_required, max_concurrent_sessions, api_key_rotation_days. Validates: max_concurrent_sessions > 0 if set, api_key_rotation_days > 0 if set (DR-27). Returns the upserted row.
  • getEffectiveMfaRequired(tenantId) — read both password_policy.mfa_required and tenant_security_policy.mfa_required for the tenant; return password_policy.mfa_required OR COALESCE(tenant_security_policy.mfa_required, false) (DR-28). Must never read only tenant_security_policy — skipping the global floor is a security defect.

API Key Management

  • createApiKey(tenantId, serviceAccountId, name, scopes?, expiresAt?, callerActorId) — generate random raw key (vrk_<random>), compute key_hash (bcrypt/SHA-256), derive key_prefix (first 12 chars). INSERT api_key. Return {apiKeyId, rawKey} — raw key returned ONCE and immediately discarded by the service layer. Caller (admin UI) shows it once to the tenant; it is never retrievable again.
  • revokeApiKey(apiKeyId, callerActorId) — set api_key.status='revoked' + revoked_at (key is immediately invalid; this is the revocation audit event). Do NOT set deleted_at here — deleted_at is set separately only on explicit deletion/cleanup (e.g., purging old revoked keys from the listing). Revocation and soft-deletion are distinct operations: revoked keys remain visible in the admin listing as audit history; deleted keys are hidden.
  • verifyApiKey(clientId, rawKey) — look up service_account by client_id; fetch api_key rows for the service account WHERE status='active' AND key_prefix matches the inbound key's prefix; verify hash(rawKey) = key_hash for each candidate. Returns the matching api_key.id + service_account.id + actor.id on success; null on failure. Does NOT write identity_access_event (hot-path auth — events written by the calling service).
  • rotateExpiredApiKeys() — background sweep: for all tenants with tenant_security_policy.api_key_rotation_days IS NOT NULL, expire api_key rows WHERE status='active' AND created_at < now() - interval 'N days'. Sets status='expired'. Notifications (to the tenant admin) are emitted via the notifications module.

Password Policy

  • getPasswordPolicy() — SELECT password_policy WHERE is_active = true. Always returns exactly one row (partial UNIQUE on is_active WHERE is_active=true enforces singleton). Read at login and profile-update; governs email+password users only.

7. Permission Resolution

This is the core algorithm IdentityService implements for every authorization check. Given (actorId, tenantId, permissionCode), return allow or deny. The same algorithm applies to human users, service accounts, and AI agents.

Step 1 — Direct role assignments

Site-context shortcut (evaluate first): If the actor is acting in a site context (site_id is known), fetch user_site_assignment WHERE tenant_user_id = (tenant_user for this actor/tenant) AND site_id = siteId AND deleted_at IS NULL. If a row exists AND user_site_assignment.site_role_id IS NOT NULL: use only that site_role_id as the sole role for this resolution — it replaces (does not supplement) the actor's tenant-level role_assignment rows for the site context. Skip the remainder of Step 1 and proceed to Step 2 with only {site_role_id} in the role set. If site_role_id IS NULL, or if no user_site_assignment row exists for this site, fall through to the standard path below.

Standard path (no site override): Collect all role_assignment rows for actor_id = actorId AND tenant_id = tenantId WHERE status = 'active' AND starts_at <= now() AND (ends_at IS NULL OR now() < ends_at). Extract the set of role_id values.

Step 2 — Group role assignments

For each actor_group_member row WHERE actor_id = actorId AND deleted_at IS NULL AND the linked actor_group.status = 'active': collect all role_assignment rows for actor_group_id = group.id AND tenant_id = tenantId WHERE status = 'active' AND starts_at <= now() AND (ends_at IS NULL OR now() < ends_at). Union these role_ids into the role set from Step 1.

Step 3 — Permission expansion per role

Skip inactive roles: Before expanding, filter out any role_id where role.is_active = false. Deactivated roles are excluded from permission resolution regardless of whether the actor holds an active assignment to them.

For every remaining role_id, expand to individual permissions by unioning three sources:

3a — Direct permissions: SELECT permission_id, effect FROM role_permission WHERE role_id = $role_id.

3b — Inherited permissions (recursive CTE, depth cap 5):

WITH RECURSIVE ancestors AS (
  SELECT id, parent_role_id FROM identity.role WHERE id = $role_id
  UNION ALL
  SELECT r.id, r.parent_role_id FROM identity.role r
    JOIN ancestors a ON r.id = a.parent_role_id
  -- depth cap enforced by service layer before query (reject if ancestor chain > 5)
)
SELECT rp.permission_id, rp.effect FROM identity.role_permission rp
  JOIN ancestors a ON rp.role_id = a.id
  WHERE a.id != $role_id  -- exclude the role itself (already covered in 3a)

3c — Bundle permissions: SELECT pgp.permission_id, resolve effect as 'allow' (bundles do not carry a per-permission effect — bundle membership implies allow) FROM role_permission_group rpg JOIN permission_group_permission pgp ON pgp.permission_group_id = rpg.permission_group_id WHERE rpg.role_id = $role_id AND rpg.deleted_at IS NULL.

Union all three sources across all roles from Steps 1 and 2. Apply deny-beats-allow: if any source produces effect='deny' for a permission_id, the final effect for that permission_id is deny, regardless of any allow from another source.

Step 4 — User permission overrides

SELECT permission_id, effect, scope_type, scope_id, scope_code FROM user_permission_override WHERE tenant_user_id = (tenant_user for actor/tenant) AND permission_id = $permissionId AND deleted_at IS NULL AND starts_at <= now() AND (ends_at IS NULL OR now() < ends_at).

Apply overrides on top of the Step 3 result. Rules:

  • deny beats allow across all overrides and the Step 3 result.
  • More-specific scope wins over less-specific: site or module scope overrides tenant scope when they conflict.
  • Scope matching: scope_type='tenant' always applies; scope_type='site' applies only if the actor is acting in that site context (scope_id = request siteId); scope_type='module' applies only if the current request is in that module (scope_code = request moduleCode).

Step 5 — Final result

Return allow if the resolved effect for permissionCode is allow; return deny otherwise (including the case where no role or override produces any effect for the permission — default-deny).

Caching / performance intent

Permission resolution is called on every authorized request. IdentityService should maintain a short-lived per-actor effective-permission cache (keyed by actorId + tenantId + [siteId]). Cache must be invalidated on any mutation that affects the actor's effective permissions: role_assignment change, role_permission change (for any role held by the actor), user_permission_override change, actor_group_member change, actor.status change. Cache TTL and invalidation strategy are IdentityService implementation decisions — the schema supports any approach.

Effective-permission output shape

For admin UI purposes, IdentityService should expose a resolveEffectivePermissions(actorId, tenantId) method that returns the full effective permission set as {permissionCode, effect, source}[] where source is one of direct_role, inherited_role, bundle, override. This is used for the "what can this user do?" admin view.

8. Agent Authorization

AI agents are first-class actors in the identity schema. They share the same authorization mechanism as human users — same actor root, same role_assignment table, same permission resolution algorithm.

Authorization check for agent actions

Before dispatching an agent to perform a task, IdentityService (or the dispatching service) must verify both:

  1. Skill check: does agents.agent_skill_assignment contain a row WHERE agent_identity_id = agentId AND skill_version_id = (the skill version required for this task)? A skill declares competency — what the agent is capable of doing. No skill = no dispatch. Rewritten 2026-07-17 (DR-38, Identity's 6th reopen, Phase 6 of the agents-v2/v3 build): this check previously read identity.agent_skill_assignment (agent_id + agent_skill_id, skill-identity granularity). identity.agent_skill/identity.agent_skill_assignment were dropped and are superseded by agents.skill_definition/skill_version/agent_skill_assignment (built Phase 5) — the check now joins through agents.skill_versionagents.skill_definition and operates at skill-VERSION granularity, not skill-identity granularity. See PROJECT_DECISIONS #67.

  2. Permission check: run the standard 5-step permission resolution (§7) for (agentId, tenantId, requiredPermissionCode). A role assignment grants what the agent is authorized to access. No permission = no dispatch.

Both must pass. A skill without the permission means the agent lacks access. A permission without the skill means the task is outside the agent's designated capability set. The two layers are orthogonal (DR-25).

Agent-elevation approval gate

role.requires_approval_for_agents (boolean, default false) flags roles whose assignment to an agent actor routes through human review rather than being granted directly. This column was added as part of the 2026-07-06 autonomy-first backfill (PROJECT_DECISIONS #19, DR-30); service-layer enforcement was added 2026-07-06 (urgent Part D finding — see PROJECT_DECISIONS #21). assignRole() now branches on it: when the assignee is an agent actor and the target role has requires_approval_for_agents=true, it creates a pending access_request instead of an immediate role_assignment, reusing the same review path already used for human elevation (§13) — submitAccessRequest()/approveRequest()/denyRequest(). The gate is agent-only (human/service_account assignees always assign directly, even to the same role) and is skippable only from approveAccessRequest()'s own call site (skipApprovalGate: true), which prevents that call from looping back into submitAccessRequest().

Agent-authority passport (A5)

agent_duty_grant (built 2026-07-06, PROJECT_DECISIONS.md #22) is the A5 "agent identity & permission passport" (AI_CAPABILITY_PLANE.md A5): per (agent_identity, permission) authority grant recording how autonomously an agent may exercise a permission it already holds via role_assignmentrole_permission, and within what spend/quantity ceiling. Full column detail in schema_docs/identity.md.

Relationship to the agent-elevation gate above: the two mechanisms are sequential, not competing. role.requires_approval_for_agents governs the moment an agent comes to HOLD a role at all (grant-time gate — "may this agent even have this access without a human sign-off first"). agent_duty_grant governs, for a permission the agent already legitimately holds, how autonomously it may exercise that permission and within what limit (execution-time governance record — "how freely may it use it once held"). Neither conflicts with nor duplicates the other.

Authority levels: may_act_alone / draft_only / needs_approval — a deliberate simplification of A4's full 7-rung ladder (L0–L6) down to the 4-state framing A5 itself uses ('never' = no grant row exists, not a stored value). may_act_alone combined with a populated spend_limit_cents/quantity_limit functionally expresses A4's L5 (execute-within-limits); combined with no limit, it expresses L6 (autonomous-except-exceptions) — no separate enum value needed for that distinction.

Enforcement is additive, not a replacement: agent_duty_grant never overrides permission resolution's allow/deny (§7) — it only constrains how freely an already-permitted action may be exercised. A build-time service-layer requirement (not yet implemented — schema-only this pass): absence of an agent_duty_grant row for a permission the agent holds via role must default to the MOST conservative treatment (draft_only or stricter), never to unrestricted execution — otherwise a broad role grant silently becomes a blank check for any permission it bundles that has no explicit duty-grant row.

Design history (DR-31, DR-32): two bugs were caught by independent adversarial verification during design, before any migration was written — see schema_docs/identity.md's agent_duty_grant section for full detail. (DR-31) The unique index deliberately excludes scope_id/scope_code — including them, as the original design mistakenly did, lets Postgres treat NULL as distinct and silently allows two active tenant-scoped grants for the same (agent, permission) to coexist; regression-tested (identity-agent-duty.spec.ts D1). (DR-32) scope_id is a real, enforced FK to multi_loc.site — the original design deferred it, incorrectly citing user_permission_override.scope_id's precedent, which no longer applies now that multi_loc is locked.

Not built this pass (schema-only): no IdentityService methods (e.g. grantAgentDuty()/revokeAgentDuty()/checkAgentDutyAuthority()) exist yet — see OPEN_ITEMS. Also deferred: outbound integration/external-tool scope (no registry exists to FK against) and cumulative/period spend tracking (a real, currently-unmitigated security limitation — a per-action-only limit does not protect against volume-based abuse across many small actions) — both logged to OPEN_ITEMS with explicit triggers.

DR-33: Remediation Phase 2 (2026-07-08) — PK generation retargeted to platform.uuid_generate_v7(). identity_access_event and the 3 catalog-wiring join tables (permission_group_permission, role_permission, role_template_permission_group, sod_rule_permission) had their id column DEFAULT changed from gen_random_uuid() to platform.uuid_generate_v7() — all 4 are self-documented in their own Drizzle source as append-only (no updated_at, no deleted_at). UUIDv7 is time-ordered, keeping future time-range partitioning possible on these tables without a PK rewrite — impossible once data lands on a random UUIDv4 PK. DEFAULT-only; no column/table count change. See schema_docs/identity.md's "Remediation Phase 2" subsection and PROJECT_DECISIONS #38.

DR-34: Remediation Phase 3 (2026-07-08) — agent kill-switch on agent_identity. agent_identity gained 4 columns — status (text, NOT NULL DEFAULT 'active'; CHECK IN 'active'/'suspended'/'killed'), suspended_at, suspended_by_actor_id (FK → identity.actor, nullable even when suspended/killed — a system-initiated suspension may have no specific human actor to attribute), and suspension_reason — plus a consistency CHECK requiring suspended_at to be populated if and only if status != 'active' → identity is now 35 tables / 400 cols (up from 396). Additive and zero-risk: 247 live agent_identity rows at build time all satisfy the new CHECK by construction of the status DEFAULT. 'killed' is service-layer terminal only — not DB-enforced. No trigger blocks a killed → active transition; IdentityService (not yet built for this column) is responsible for treating 'killed' as a one-way door, the same way role.requires_approval_for_agents and agent_duty_grant's authority levels are enforced entirely at the service layer today.

This column is the missing top-of-agent link in a precedence chain of independently-owned half-mechanisms, evaluated in this order before any agent action proceeds: (1) tenant status, (2) platform.ai_credit_account status (platform-owned; there is no ai.ai_credit_account table — a citation corrected before build), (3) this agent's own agent_identity.status — new, this item, (4) identity.agent_duty_grant, (5) skill assignment (agents.agent_skill_assignment, skill-version granularity as of 2026-07-17 — moved out of identity.agent_skill_assignment, which was dropped; see DR-38, PROJECT_DECISIONS #67), (6) role assignment, (7) feature flags. No single mechanism was authoritative before this column existed, and this column does not make the chain authoritative alone — it fills the missing link, still evaluated in order with the rest. Every module consuming identity.agent_duty_grant (crm, inventory, pos, orders, purchasing, tax, billing, payments, admin) is affected. See schema_docs/identity.md's "Remediation Phase 3" subsection and PROJECT_DECISIONS #39.

DR-35: Operator identity build (2026-07-09) — identity.operator + identity.operator_role_assignment, replacing the identity_user.is_platform_user overlay. A Vrida operator (cross-tenant SaaS staff — admin console access) was previously just an identity_user row with is_platform_user=true, structurally indistinguishable from a tenant employee to anything reading actor_type alone. Isolated into its own shared-PK detail table off identity.actor (mirroring service_account/agent_identity's own precedent), with actor.actor_type CHECK widened to add 'operator'trg_tenant_user_actor_type_check needed zero modification, since 'operator' != 'user' was already rejected by it (live-reproduced). operator carries its own kill-switch (status/suspended_at/suspended_by_actor_id/suspension_reason, mirroring agent_identity's Remediation Phase 3 precedent) rather than reusing bare actor.status. operator_role_assignment mirrors role_assignment's revocable-grant shape, with a small CHECK-enumerated role_code list (super_admin/admin/support — a design-phase adversarial pass cut 3 invented values, one of which collided with an existing, differently-scoped role of the same name in identity.role) and a NULL-safe self-issue guard CHECK (assigned_by_actor_id IS NULL OR assigned_by_actor_id != operator_id — nullable to allow the bootstrap assignment, which has no prior assigner). Both tables get an explicit REVOKE SELECT, INSERT, UPDATE, DELETE ... FROM authenticated (closing the automatic ALTER DEFAULT PRIVILEGES grant every new identity-schema table inherits per Remediation Phase 1) plus RLS-enabled-with-zero-policies as an independent second layer — all reads/writes go through getAdminDb(), never tenantDB()/authenticated. AdminAuthGuard now resolves the signed-in operator by supabase_auth_user_id against identity.operator exclusively; it returns 401 if no operator row exists at all (genuine non-operator — a behavior change from the old model, which always resolved to some identity_user row and returned 403 for non-platform users) and 403 only when a real operator row exists but status != 'active'. identity_user.is_platform_user is NOT dropped this pass — still readable/writable, no longer read by any auth path, superseded in practice — see OPEN_ITEMS for the drop-trigger. → identity is now 37 tables / 422 cols (up from 400). See schema_docs/identity.md's new identity.operator/identity.operator_role_assignment entries and PROJECT_DECISIONS #45.

Agent type catalog

agent_type_catalog defines Vrida-shipped agent types (e.g., inventory.reconcile, reporting.financial_summary). When provisionAgent() is called, the agent's default skills are seeded from the type's catalog entry. agent_type_catalog is writable only via service_role and seed migration — adding a new agent type never requires a DDL migration (DR-24).

Audit attribution

Every action taken by an agent is attributed to the agent's actor.id in identity_access_event. Agents act independently — there is no mandatory owner user. The actor_id column on identity_access_event carries the agent's actor UUID. The consuming audit system (when audit module is built) will generalize audit.audit_log.actor_user_id to identity.actor with an actor_type column to accommodate all three actor types uniformly (OPEN_ITEMS).

Tenant context for agents

Agents obtain tenant scope via agent_identity.tenant_id — not via tenant_user rows. Agents never get tenant_user rows; the trigger trg_tenant_user_actor_type_check blocks this at the DB level (DR-26). RLS for agent-written rows uses agent_identity.tenant_id for scope enforcement.

9. Authentication

v1 — Email/Password + Google (Supabase Auth)

Supabase Auth owns credentials, JWT generation, MFA enforcement, and OAuth token handling. Identity owns the link:

Registration path:

  1. Supabase Auth creates the auth.users row (email/password or Google OAuth).
  2. A Supabase Auth webhook fires to Vrida's registration endpoint.
  3. IdentityService calls provisionUser(supabaseAuthUserId, email, fullName?) — inserts actor then identity_user with the Supabase UUID stored as supabase_auth_user_id.

Login path:

  1. Supabase Auth authenticates the credential; returns JWT.
  2. Vrida API reads the JWT; extracts sub (= supabase_auth_user_id).
  3. IdentityService looks up identity_user WHERE supabase_auth_user_id = sub AND deleted_at IS NULL.
  4. IdentityService reads password_policy (singleton active row) + tenant_security_policy for the target tenant; checks effective MFA (DR-28).
  5. IdentityService calls createSession(actorId, supabaseSessionId, ...) → returns session_id for the request context.
  6. All subsequent requests carry the session_id for event correlation.

Password policy enforcement: IdentityService reads the password_policy singleton at login and profile-update and communicates the rules to the auth layer. Password enforcement itself (hashing, comparison) remains Supabase Auth's domain. password_policy governs email+password users only — Google/SSO users bypass it.

Post-v1 — SSO (SAML/OIDC) ⚠️ DEFERRED

sso_provider schema is complete and locked. Implementation deferred. When built:

  • provider_type determines the auth flow (SAML vs OIDC).
  • allowed_domains drives email-domain → provider routing at login.
  • require_sso forces domain-matched users to the SSO path.
  • auto_provision_users creates identity_user + tenant_user on first successful SSO login, using default_role_id as the initial role.
  • client_secret_ref and certificate_ref are references to a secret manager — IdentityService resolves them at request time, never caches raw secrets.
  • GIN index on sso_provider.allowed_domains deferred to OPEN_ITEMS (when SSO domain-routing query is built).

Post-v1 — SCIM 2.0 ⚠️ DEFERRED

scim_config schema is a skeleton. The SCIM 2.0 API endpoints (/Users, /Groups), provisioning/deprovisioning lifecycle, and IdP testing are deferred. When built:

  • scim_config.bearer_token_ref is a secret-manager reference; SCIM requests must present this token for IdP authentication.
  • SCIM user provisioning maps to identity_user + tenant_user creation; tenant_user.external_id stores the SCIM externalId.
  • SCIM group provisioning maps to actor_group; actor_group.external_id stores the SCIM Group ID.
  • identity_access_event.event_type is missing scim_user_provisioned and scim_user_deprovisioned CHECK values — add when the SCIM event writer is authored (OPEN_ITEMS).

10. Session Management

identity_session is a global record — no tenant_id. One session spans the full authentication context; the user selects a tenant within a session. Session rows are permanent audit history (no deleted_at); lifecycle is via ended_at + end_reason.

Session lifecycle:

  • Created at login via createSession(). supabase_session_id is nullable — platform-initiated sessions (admin, SCIM) have no Supabase session.
  • Active while ended_at IS NULL. last_active_at updated on every authenticated request via updateLastActive().
  • Ended by: explicit logout (end_reason='logout'), idle timeout sweep ('expired'), admin revoke ('admin_revoked'), or log-out-all-devices ('all_devices_revoked').

tenant_security_policy knobs (per-tenant overlays):

  • session_timeout_minutes — idle timeout (NULL = platform default constant). Enforced by sweepIdleSessions() background job. Because an actor may belong to multiple tenants, the sweep applies the most restrictive (shortest) timeout across all tenants the actor is a member of; if the actor belongs to no tenant (platform user) the Vrida default constant applies.
  • max_concurrent_sessions — cap on simultaneous active sessions per actor. Enforced at session creation time by createSession(). Must be > 0 if set (DR-27).
  • mfa_required — OR-reconciled with global password_policy.mfa_required (DR-28); communicated to Supabase Auth's MFA enforcement flow.
  • api_key_rotation_days — drives rotateExpiredApiKeys() sweep.

Session correlation: identity_session.id is written as session_id on every identity_access_event row emitted during the session. Enables forensic query "all events in session S" via direct UUID join. Pre-session events (login flow before session is created) and platform-level events have session_id = NULL.

11. Support Access

Vrida operators (identity.operator — see DR-35; superseded the old identity_user.is_platform_user = true overlay 2026-07-09) never get tenant_user rows. They access tenant data exclusively via time-boxed support_access_grant records.

Authorization rule (load-bearing): An operator may set the tenant context if and only if a support_access_grant exists WHERE vrida_user_id = actor.id AND tenant_id = targetTenantId AND status = 'active' AND now() BETWEEN starts_at AND ends_at. Do not rely on status = 'active' alone — always check the time window. Revocation and natural expiry both stop access on the next request.

Every use is logged: recordSupportAccess() writes identity_access_event (support_access_used) on every request made under a support grant. This is the audit trail for "Vrida staff accessed tenant X at time T."

Expiry: expireSupportGrants() sweep sets status='expired' for all grants past ends_at. The transition from active to expired does not write an event (natural expiry is self-documenting from the grant record). Explicit revocation writes identity_access_event (support_access_revoked).

12. SoD Detection

Vrida defines SoD rules globally (sod_rule). Tenants cannot create or modify rules — they may waive individual violations. Detection is detect-and-flag, never block (DR-19).

Detection trigger — hybrid (DR-20):

  • On-change: triggered by any mutation to role_assignment or user_permission_override for an actor. detectSodViolations(tenantId, actorId) fires after the commit. Never blocks the triggering mutation.
  • Daily sweep: dailySodSweep() evaluates all actors × all active rules across all tenants. Catches violations introduced by role_permission catalog changes (adding/removing a permission from a role affects all role holders — on-change cannot track this efficiently).

Detection query: for a given actor and rule, resolve the actor's full effective permission set (§7). Then:

SELECT COUNT(*) FROM identity.sod_rule_permission srp
WHERE srp.sod_rule_id = $rule_id
  AND srp.permission_id IN (actor_effective_permission_ids)
HAVING COUNT(*) = (
  SELECT COUNT(*) FROM identity.sod_rule_permission WHERE sod_rule_id = $rule_id
)

If the result returns a row, the actor holds all permissions in the rule → violation detected.

Waiver check before new violation: if a sod_violation row exists for (tenant_id, actor_id, sod_rule_id) WHERE status='waived' AND (waiver_expires_at IS NULL OR now() < waiver_expires_at): skip creating a new violation. Update last_detected_at on the waived row instead.

Violation lifecycle: open → acknowledged / waived / resolved. acknowledged → waived / resolved. Rows are permanent (no deleted_at); only status transitions are permitted. After resolution, a new open violation can be created if re-detected.

SoD rule guard: a rule with fewer than 2 permissions in sod_rule_permission may not have is_active = true. Service layer enforces this before any is_active SET. The HAVING COUNT(*) = N detection query naturally skips under-populated rules even if this guard is bypassed.

Decision provenance (DR-30): sod_violation.decision_snapshot (jsonb, nullable) was added in the 2026-07-06 autonomy-first backfill (PROJECT_DECISIONS #19) to carry the permission-set/role-combination active at detection time, for violations detected on agent-driven mutations. The exact key shape is deliberately deferred — populate at detectSodViolations() write time once the shape is finalized. Not required for human-attributed violations.

Violations surface in compliance UI via the audit module's compliance cluster — not inline during the operation that triggers them.

13. Access Governance

v1 — Single-approver flow:

access_request is a permanent tenant-scoped record (no deleted_at). The full request lifecycle is written to identity_access_event using the five access_request event types (access_request_submitted, access_request_approved, access_request_denied, access_request_withdrawn, access_request_expired).

On status → 'approved':

  • request_type = 'role'assignRole(tenantId, requesterActorId, requestedRoleId, startsAt, endsAt, reviewedByActorId) — creates role_assignment.
  • request_type = 'permission_override'grantOverride(tenantId, tenantUserId, requestedPermissionId, 'allow', requestedScopeType, requestedScopeId, startAt, endsAt, reviewedByActorId) — creates user_permission_override.

Both seam operations write identity_access_event (role_changed / permission_override_applied) in addition to the access_request_approved event.

Immutability: access_request rows are immutable after creation except for: status, approver_actor_id, reviewed_by_actor_id, reviewed_at, review_note. No other column may be updated post-insert.

Post-v1 — Multi-step workflow engine ⚠️ DEFERRED: approval_workflow and approval_step tables are deferred (OPEN_ITEMS). When built, they attach to access_request via a nullable workflow_id UUID FK → approval_workflow column added at that time. When workflow_id IS NULL: single-approver v1 path applies. When workflow_id IS NOT NULL: the workflow engine drives multi-step approval, writing the same status transitions. See DR-22 and Decision 11a in identity_expansion_intent.md.

14. Multi-Site Access

user_site_assignment maps a tenant_user to specific sites with an optional site-specific role override.

Site role override in permission resolution (Step 1): When an actor is acting in a site context (site_id known at request time): if user_site_assignment.site_role_id IS NOT NULL for that actor/site pair, the site_role_id is treated as a direct role assignment that replaces (not supplements) the actor's tenant-level roles for that site context. If site_role_id IS NULL, the actor's tenant-level roles (from role_assignment) apply at that site.

Multi-site guard: user_site_assignment.site_id is now a real composite FK → multi_loc.site(id, tenant_id) (user_site_assignment_site_tenant_fkey, wired 2026-07-10). The deferral turned out to matter concretely before it closed: a mandatory pre-migration orphan-check audit run for the 2026-07-10 fix #3 reopen (DR-36, below) found this column held one live orphaned value with no matching multi_loc.site row. That orphan was investigated (isolated dev-seed fixture junk) and deleted in the same-day follow-up reopen that wired the FK — see §21 and PROJECT_DECISIONS #54. IdentityService's PlatformService-based validation remains as defense-in-depth alongside the DB constraint.

Primary site: user_site_assignment.is_primary_site = true marks the actor's default site. tenant_user.default_site_id is now a real composite FK → multi_loc.site(id, tenant_id) (tenant_user_default_site_tenant_fkey, wired 2026-07-10) pointing to the preferred site for session context. See PROJECT_DECISIONS #54.

DR-36: Header/Line Remediation reopen (2026-07-10), fix #3 — identity.invitation_site_assignment + user_site_assignment.created_from_invitation_site_assignment_id (37→38 tables, 422→429 cols). A new table, invitation_site_assignment (6 cols), stages intended site access at invite time — reusing user_site_assignment's own shape almost verbatim, since that table is the correctly-modeled POST-acceptance version of this exact concept. site_id deliberately carries no FK to multi_loc.site, matching user_site_assignment.site_id's own pre-existing gap, not a new one. identity.invitation gained a prerequisite UNIQUE(id, tenant_id), and its site_assignments JSONB column is now deprecated in place (comment only — confirmed zero live rows, so no backfill was needed or possible). user_site_assignment gained a new nullable, composite-FK reciprocal-traceability column, created_from_invitation_site_assignment_id — corrected from the original design draft's bare-FK mistake (the same bug class independently caught on purchasing.vendor_credit_line/billing.ar_charge_line in the prior batch) before this build even started. Two mandatory pre-migration audits, both disclosed prominently rather than glossed over: (1) a JSONB-inspection audit confirmed identity.invitation had zero live rows, so there was nothing to backfill into the new table; (2) a mandatory orphan-check audit found exactly ONE live orphaned row in user_site_assignment.site_id (id 4cd177f7-6f3a-42aa-a977-6fe0b34e89e6, site_id 3587ced4-62ba-47a8-b5ba-4cb0ae43ce29, absent from multi_loc.site); tenant_user.default_site_id has zero non-null rows (zero risk there). Because of this orphan, the optional opportunistic bundle the design doc suggested — wiring user_site_assignment.site_id, tenant_user.default_site_id, and this fix's own new site_id all to real multi_loc.site FKs in one pass, cheaper than 3 separate future reopens — was deliberately NOT taken. This is flagged for a human decision (null out the orphan vs. correct the reference), not unilaterally resolved. See §21 for the full record, including the pasted, attributed independent-verification summary, and PROJECT_DECISIONS #52.

DR-37: Header/Line Remediation follow-up (2026-07-10), Identity's 5th reopen — resolves DR-36's human decision (38 tables / 429 cols, unchanged). The orphaned user_site_assignment row DR-36 flagged was investigated: its tenant had exactly 5 rows total anywhere in the schema, all boilerplate/system rows, zero business data — isolated dev-seed fixture junk, confirmed via a DB-wide tenant-isolation scan (not just the original claim). DELETED (site_id is NOT NULL, nulling wasn't an option). All 3 columns named in DR-36's bundle — user_site_assignment.site_id, tenant_user.default_site_id, invitation_site_assignment.site_id — are now real composite FKs → multi_loc.site(id, tenant_id) (multi_loc's 1st reopen since its 2026-06-29 lock, adding the prerequisite UNIQUE(id, tenant_id) on site). Pure constraint-shape change — no column/table count impact. 2 columns confirmed still genuinely deferred by independent verification: user_permission_override.scope_id and access_request.requested_scope_id — the latter a previously-untracked 4th sibling surfaced during this entry's own docs pass, not part of DR-36's bundle. 9 live-reproduction scenarios (same-tenant/cross-tenant/orphan × 3 columns) all passed, including one isolating the new invitation_site_assignment_site_tenant_fkey from the pre-existing invitation_id FK. Independent adversarial verification: CLEAN, zero findings — full detail in §21. See PROJECT_DECISIONS #54.

DR-38: Phase 6 of the agents-v2/v3 build (2026-07-17), Identity's 6th reopen — drops agent_skill/agent_skill_assignment, superseded by the agents module (38→36 tables, 429→410 cols). identity.agent_skill (9 cols) and identity.agent_skill_assignment (10 cols) were DROPPED — both are fully superseded by agents.skill_definition/skill_version/agent_skill_assignment (built Phase 5, PROJECT_DECISIONS #66). Migration: packages/db/migrations/20260717000000_identity_reopen_drop_legacy_agent_skill.sql. A mandatory pre-migration audit found agent_skill_assignment had 0 live rows and agent_skill had 13 live rows, all test debris (test.skill.%/ti3.skill.% patterns) — cleaned up in the migration, not real data. IdentityService's skill methods (listSkillCatalog, assignSkillToAgent, removeSkillFromAgent, listAgentSkillAssignments, and authorizeAgentAction()'s skill-check half — see §8 above) were rewritten in apps/api/src/identity/identity.service.ts to join through agents.skill_version/skill_definition instead of the dropped tables. Assignment is now at SKILL VERSION granularity (agents.agent_skill_assignment.skill_version_id), not skill-identity granularity — callers now pass a skillVersionId (renamed from skillId), and listAgentSkillAssignments()'s return field is now skillVersionId (renamed from skillId). listSkillCatalog()'s return shape changed to {id, code, name, lifecycleStatus} (dropped category/moduleCode — no equivalent on the flatter agents.skill_definition catalog). Zero controllers/DTOs reference any of these 4 methods today (grep-confirmed) — no live HTTP consumer was affected. This same reopen also added a new agent_reader Postgres role (NOLOGIN NOINHERIT, mirrors consumer_authenticated's shape) and an agentReaderDB() connection helper (packages/db/src/client.ts), scoped to read files.document_chunk/document_index and execute signals's get_*_as_of() functions — documented in files.md/signals.md; agent_reader has zero real call sites today (no AgentsService exists yet), logged to OPEN_ITEMS. Full apps/api suite green at 1205/1205 (up from 1201 — 4 new agent_reader regression tests). This closes the entire 6-phase agents-v2/v3 build (Phase 1 platform, Phase 2 ai, Phase 3 semantics, Phase 4 signals, Phase 5 agents, Phase 6 this identity reopen). See PROJECT_DECISIONS #67.

15. Audit Seam

Identity's own event log is identity_access_event — append-only, covering both Vrida-initiated auth/session events and application-level authorization events. Raw Supabase auth.* internal events stay in Supabase's own audit log; everything Vrida's service layer initiates lives here. This is distinct from the audit module's central audit.audit_log hash-chain. IdentityService writes identity_access_event for all auth/session and authorization-domain events; the audit module reads identity_access_event as a source for compliance reporting.

Correction note (2026-06-29): The original 23 app-level values predated identity_session (added Batch B Pass 2). Once Vrida has its own session table, Vrida-initiated session events (login, all_sessions_revoked, etc.) also belong here. The Drizzle schema was separately authored with a divergent 22 auth-only set. The correct set is the union of both: 39 values. See migration 20260629010000_identity_access_event_reconcile.sql.

Event types written by IdentityService (39 total in event_type CHECK):

Auth / session (Vrida-initiated; not raw Supabase internal auth events):

Event Written when
login createSession() — Vrida session created at login
logout endSession() with end_reason='logout'
login_failed Vrida-side login failure (account deactivated, etc.)
mfa_challenge MFA prompt issued by Vrida
mfa_success MFA verification succeeded
mfa_failed MFA verification failed
password_reset_request Password reset initiated
password_reset_complete Password reset completed
password_changed Password changed
account_locked Account locked (too many failures or admin action)
account_unlocked Account unlocked
session_revoked Single session revoked (endSession with admin_revoked)
all_sessions_revoked revokeAllSessions() — all actor sessions ended
sudo_granted Elevation / sudo mode granted
sudo_revoked Elevation / sudo mode revoked

SSO (live names; sso_login_mapped from original doc replaced):

Event Written when
sso_login SSO login succeeded
sso_login_failed SSO login failed

Authorization decisions:

Event Written when
tenant_selected Actor selects a tenant context after login
permission_denied Resolution returns deny for a specific check
site_access_denied Actor lacks user_site_assignment for a requested site

Role / permission management:

Event Written when
role_changed Role assignment created, revoked, or group membership changed
permission_override_applied grantOverride() called

Invitation lifecycle:

Event Written when
invitation_sent sendInvitation() called
invitation_accepted acceptInvitation() succeeds
invitation_expired expireInvitations() sweep
invitation_revoked revokeInvitation() called

Support access lifecycle:

Event Written when
support_access_granted grantSupportAccess() called
support_access_used recordSupportAccess() — every request under a grant
support_access_revoked revokeSupportAccess() called
support_access_denied checkSupportAccess() returns null (invalid or expired)

SoD governance lifecycle:

Event Written when
sod_violation_detected detectSodViolations() creates a new open violation
sod_violation_acknowledged acknowledgeViolation() called
sod_violation_waived waiveViolation() called
sod_violation_resolved resolveViolation() called

Access request lifecycle:

Event Written when
access_request_submitted submitAccessRequest() called
access_request_approved approveRequest() called
access_request_denied denyRequest() called
access_request_withdrawn withdrawRequest() called
access_request_expired expireAccessRequests() sweep

session_id correlation: all event writes during an authenticated session must carry session_id = identity_session.id. Pre-session events (login flow) set session_id = NULL.

audit.audit_log seam: when the audit schema is designed and built, audit.audit_log.actor_user_id will be generalized to identity.actor and gain an actor_type column. Identity provides the actor UUID and type on all events. This is tracked in OPEN_ITEMS (trigger condition met since Batches A + C are locked).

16. Key Invariants & Guards

These invariants are DB-enforced, service-layer-enforced, or critical contract rules derived from the DRs. IdentityService must uphold all of them.

Shared-PK insertion order (DR-11): Always INSERT actor first (UUID generated on actor), then INSERT the detail table (identity_user, service_account, or agent_identity) with the same UUID as its PK. The detail tables have no gen_random_uuid() default on id — passing the wrong UUID or inserting in the wrong order will fail or corrupt the shared-PK invariant.

Human-only tenant_user (DR-26): Trigger trg_tenant_user_actor_type_check (BEFORE INSERT OR UPDATE on tenant_user) reads actor.actor_type for NEW.actor_id and raises EXCEPTION if not 'user'. Service accounts and agents obtain tenant scope via service_account.tenant_id / agent_identity.tenant_id — never via tenant_user. IdentityService must validate actor_type = 'user' before calling joinTenant() — the trigger is the second-layer guard. Edge case: because this is a BEFORE trigger, if actor_id references a non-existent actor, the trigger fires before the FK check and the error message says "must be user" rather than FK violation — this only occurs in direct SQL bypassing the ORM.

Role inheritance cycle prevention (DR-12): Before setting parent_role_id on a role, service layer must walk the ancestor chain via recursive CTE and reject if the target role appears anywhere in the chain. Depth cap 5 — reject any assignment that would produce a chain deeper than 5. The only DB-level guard is CHECK (parent_role_id IS NULL OR parent_role_id != id) (self-loop prevention). Multi-hop cycles require app-level detection.

Cross-tenant role inheritance guard (DR-12): parent_role_id may point to a role with tenant_id IS NULL (built-in) or the same tenant. Pointing to another tenant's custom role is forbidden. Service layer enforces: parent.tenant_id IS NULL OR parent.tenant_id = child.tenant_id.

SoD min-2 permissions (DR-19): A sod_rule may not be activated (is_active = true) unless it has ≥2 rows in sod_rule_permission. Service layer validates before any is_active SET.

MFA OR-floor (DR-28): Effective MFA requirement = password_policy.mfa_required OR COALESCE(tenant_security_policy.mfa_required, false). Never read tenant_security_policy.mfa_required alone for the effective MFA decision — the global floor must always be OR'd in.

support_access_grant double-check (DR-10): status = 'active' AND now() BETWEEN starts_at AND ends_at — BOTH required. Status alone is insufficient; time-window check is load-bearing.

api_key raw key — never stored (DR-23): Raw API key returned exactly once from createApiKey() and immediately discarded. key_hash (bcrypt/SHA-256) and key_prefix (first 12 chars) are the only persisted values. key_hash must never be logged, serialized into API responses, or returned after creation.

SSO secrets — references only: sso_provider.client_secret_ref and certificate_ref are secret-manager reference strings (see sso_provider table-level schema notes in docs/database/schema_docs/identity.md). IdentityService resolves them at request time; the raw values never touch the DB or logs.

SoD never blocks (DR-19): Detection fires after the triggering mutation commits. The detection result does NOT block the role assignment, permission override, or any other operation. SoD is advisory-only in v1.

role_permission append-only (DR-4): Changing a role's permission means deleting the old role_permission row and inserting a new one — no UPDATE, no soft-delete. Wrap delete + insert in a single transaction.

17. Admin Pages

Identity's admin surface is the Admin UI (tenant-side) and parts of the Vrida operator console. Pages identified at Step 4; full specs authored in docs/portal/ at build (Step 12). Identified page areas:

Mockup status (2026-06-30): all 9 tenant.vrida.app page areas below are built as 🟡 sample-data UI mockups in apps/web/tenant (commit 16e63ff) — schema-accurate mock data, zero HTTP wiring, zero auth. See OPEN_ITEMS row 63.

Tenant admin UI (tenant.vrida.app):

  1. Members — list, invite, suspend/remove, manage role assignments; invite flow with token generation.
  2. Roles & Permissions — list roles (built-in + custom), create custom, clone from template, add/remove permissions and bundles.
  3. Groups — create/manage actor groups, assign members, assign roles to groups.
  4. Service Accounts — provision service accounts, issue/revoke API keys (key shown once only on issuance).
  5. AI Agents — list provisioned agents, view skills and role assignments (read-mostly for tenants).
  6. Access Requests — pending request inbox (approver view), submitted requests (requester view).
  7. Active Sessions — list active sessions (device info, last active); log out all devices.
  8. Security Policy — configure per-tenant knobs (session timeout, MFA enforcement, concurrent session cap, API key rotation policy).
  9. SoD Compliance — violation list, acknowledge/waive/resolve violations.

Vrida operator console (admin.vrida.app):

  • Support Access — grant time-boxed access to a tenant; view active grants; revoke.
  • Platform Users — provision/manage Vrida platform users (is_platform_user = true).

18. Deferred / Future Items

All items tracked in docs/open-items/OPEN_ITEMS.md. Summary for context:

Item Status Trigger
SSO (SAML/OIDC) implementation post-v1 First enterprise SSO customer
SCIM 2.0 API endpoints post-v1 First enterprise directory-sync customer
identity_access_event.event_type SCIM values post-v1 When SCIM event writer is authored
audit.audit_log actor generalization open When audit schema is designed; trigger condition met
approval_workflow + approval_step post-v1 First multi-step approval use case
consent_record post-v1 First EU/enterprise GDPR Art. 7 requirement (DR-29)
tenant_security_policy enterprise knobs (4) post-v1 Per-knob prerequisites (DR-27)
agent_execution log post-v1 Belongs in ai schema alongside ai_request (DR-24)
sod_rule_role (role-level SoD) v2 First enterprise role-level compliance requirement (DR-19)
tenant_custom SoD rules v2 First enterprise custom-rule requirement (DR-19)
Agent-elevation approval gate service-layer enforcement (role.requires_approval_for_agents) closed Enforced 2026-07-06 — assignRole() branches on it, routing gated agent assignments through submitAccessRequest() (DR-30). See PROJECT_DECISIONS #21.
agent_duty_grant service-layer methods (grantAgentDuty()/revokeAgentDuty()/checkAgentDutyAuthority()) open Schema-only since 2026-07-06 build (PROJECT_DECISIONS #22); needed before any real dispatch-time enforcement of the A5 passport exists, and before crm's own agents can consume it
agent_duty_grant outbound integration/external-tool scope deferred No integrations module/registry exists yet to FK against; when one is designed
agent_duty_grant cumulative/period spend tracking — SECURITY LIMITATION deferred spend_limit_cents is per-action only; provides no protection against volume-based abuse (many small actions under the ceiling). When the ai schema's usage ledger is built (alongside A6's execution ledger)
GIN index on sso_provider.allowed_domains deferred When SSO domain-routing query is built
role_assignment bare (tenant_id) index deferred When admin list-all-assignments UI query is built
actor.status index deferred When actor admin UI sweep query is built
Platform→identity 8 deferred FK constraints closed Phase 3 identity migration — closed 2026-06-29 in migration 0001_true_loners.sql
multi_loc.site FK constraints (3 of 5 columns) closed 2026-07-10 tenant_user.default_site_id, user_site_assignment.site_id, invitation_site_assignment.site_id now real composite FKs. user_permission_override.scope_id and access_request.requested_scope_id remain deferred — see OPEN_ITEMS. See PROJECT_DECISIONS #54.
agent_duty_grant self-issue guard (chk_agent_duty_grant_granted_by_required_and_distinct) closed Remediation Phase 1 (2026-07-08) — closes a gap where an agent could hold a duty grant with no recorded human granter, or self-issue its own grant. identity_access_event also gained RLS + a tenant_isolation policy in the same pass (the prior service_role-only premise no longer held once this phase's blanket authenticated-role GRANT landed). No column/table count change. See PROJECT_DECISIONS #37.
identity_access_event, permission_group_permission, role_permission, role_template_permission_group, sod_rule_permission — PK generation retargeted to platform.uuid_generate_v7() closed Remediation Phase 2 (2026-07-08) — DEFAULT-only change on these append-only tables' id columns (DR-33), keeping future time-range partitioning possible without a PK rewrite. No column/table count change. See PROJECT_DECISIONS #38.

IdentityService COMPLETE 2026-06-29 — all 5 build phases done. All spec-review invariants implemented and tested: B1 (detect-and-flag never blocks, DR-19), B2 (ALLOW-ONLY SoD, S1), B3 (on-approval seam reuses assignRole/grantOverride, DR-22); S1–S4 (permission resolution, deny semantics, grant-only enforcement, override scope). 50 integration tests across 5 phases, all green. See docs/modules/MODULE_BUILD_STATUS.md for full build record.

19. Cross-Module Seams

Seams are cataloged in docs/modules/CROSS_MODULE_CONTRACTS.md (referenced, not restated here). Key relationships:

  • identity → platform: all tenant-scoped identity tables FK to platform.tenant. Identity closes 8 deferred FK columns from platform at Phase 3 (4 → identity.actor, 4 → identity.identity_user). See schema doc Cross-Phase FK table.
  • identity → multi_loc: tenant_user.default_site_id, user_site_assignment.site_id, invitation_site_assignment.site_id — real composite FKs as of 2026-07-10 (PROJECT_DECISIONS #54). user_permission_override.scope_id, access_request.requested_scope_id — still deferred (OPEN_ITEMS).
  • identity → Supabase Auth: identity_user.supabase_auth_user_id is a plain UUID reference (never enforced FK — DR-1). Integrity via application code and Supabase webhooks.
  • identity → audit: identity_access_event is identity's own event log; audit.audit_log is the cross-module compliance record. audit.audit_log.actor_user_id will generalize to identity.actor when audit is built (OPEN_ITEMS).
  • All modules → identity: every module that has actor attribution or authorization checks calls IdentityService. No module reads identity tables directly.
  • admin → identity: Admin UI pages call IdentityService for all identity operations; Admin module may own tenant-side identity-admin pages.
  • notifications → identity: notifications module queries identity for actor context (delivery address, preferences). Reads via IdentityService.
  • rewards → identity: Rewards module depends on identity for authorization context.

20. AI Capability Discovery (Part D — 2026-07-06)

This module previously received only the schema-translation autonomy pass (2026-07-06 backfill: role.requires_approval_for_agents, sod_violation.decision_snapshot, and the actor-attribution FK columns already present throughout — assigned_by_actor_id, revoked_by_actor_id, created_by_actor_id, etc.). This is the full Part D module-walk against docs/ai/AI_CAPABILITY_PLANE.md, run against the actual Drizzle schema in packages/db/src/schema/identity/*.ts. Identity is the richest module for this walk so far — it is the only locked module with first-class AI-agent-identity tables (agent_type_catalog, agent_identity, agent_skill, agent_skill_assignment) and an entire Agent Authorization section (§8), so several capabilities apply directly rather than by analogy.

D1–D15 Table

Question Applies/Ruled-out Specific answer Triggered items
D1. Capture targets Ruled out (narrow) Identity has no capture-bar/command-canvas transaction surface of its own — it is the authorization layer other modules call, not a data-entry destination. The one arguable capture target is sendInvitation() ("invite Priya as a manager") and createCustomRole() via natural language, but these are admin-console CRUD actions, not the kind of "describe what happened, get a draft" flow B1 targets (POs, receipts, sales). Ruled out as a primary B1 surface; may be revisited if the Admin UI later adds an NL-driven "add this person with this access" affordance. B1
D2. Routing rules Ruled out No smart table/status placement — every identity write already has one deterministic target table per IdentityService method (e.g. assignRole()role_assignment, sendInvitation()invitation). There is no ambiguous "which table does this belong in" decision for AI to resolve. B1c
D3. Maintenance (hygiene agent) Applies Master data that rots here: (1) stale role_assignment rows — time-bounded assignments (ends_at) that quietly expire but the actor keeps acting under a cached permission set, or seasonal-staff assignments nobody revoked after the season; (2) orphaned actor_group_member rows for actors who were deactivated (actor.status='deactivated') but never removed from groups, so a dormant identity still inherits group role assignments; (3) duplicate/near-duplicate custom roles across a tenant's role table (e.g. "Cashier" and "Cashier v2" with near-identical role_permission sets) created by trial-and-error role building; (4) dangling user_permission_override rows past their intended purpose (a temporary grant that was never revoked and has no ends_at); (5) stale api_key rows never rotated past a sane age even where tenant_security_policy.api_key_rotation_days is NULL (no policy configured — no sweep applies at all); (6) unused permission catalog entries or agent_skill catalog rows nothing references (catalog-level, not tenant data, lower priority). Hygiene agent behavior: draft-only for (1)-(4) — e.g. "Actor X is deactivated but still a member of Group Y — remove?" or "Role 'Cashier v2' is 95% identical to 'Cashier' — merge?" — because merging/removing roles and permission grants is an identity/authorization change, which B3's own text says must stay draft-only ("Financial, legal, payment, tax, or identity changes remain draft/approval-only"). The one plausibly safe auto-repair is (5) flagging (not auto-rotating) keys — auto-rotating a live credential would break integrations, so even this stays draft/notify-only in this module. No fully-safe-auto-repair action was found — identity's hygiene targets are inherently authorization-sensitive. B3
D4. Error-prevention Applies Risky actions to block/warn before commit: (a) assigning a role to an actor that would create an immediate SoD violation — currently detect-and-flag after commit (detectSodViolations()), not a pre-commit warning; a pre-commit warning ("this assignment creates an SoD conflict — continue?") is a stronger UX than what's implemented today but doesn't require new schema, just calling detectSodViolations() speculatively before INSERT; (b) setting role.parent_role_id to create a cycle or cross-tenant inheritance — already hard-blocked (DR-12), not merely warned; (c) revoking the last active role assignment that grants a tenant's only user admin/owner-level access (leaving the tenant with no one who can manage identity) — no current check; (d) creating an access_request for a role/permission the requester already effectively holds (redundant request) — no current check; (e) assigning role.requires_approval_for_agents=true roles directly to an agent actor bypassing access_requestCLOSED 2026-07-06: assignRole() now branches on this (PROJECT_DECISIONS #21); direct assignment to a gated role for an agent actor is no longer possible. (a), (c), (d) are warn-not-block (identity intentionally never hard-blocks except (b)'s structural cycle case, consistent with DR-19's "SoD never blocks" governing rule); (b) and (e)'s intended behavior are hard blocks. State each reads: (a) reads role_permission+sod_rule_permission via the resolution algorithm; (c) reads all active role_assignment rows for the tenant filtered to admin-level roles; (d) reads the actor's current effective permission set (§7); (e) reads role.requires_approval_for_agents. B4
D5. Negative-space Applies Concrete gap patterns: (1) an actor row with actor_type='agent' (i.e. agent_identity exists) but zero rows in agent_skill_assignment — a provisioned agent with no declared competency, meaning §8's "both skill AND permission must pass" check will always fail dispatch — a structurally inert agent; (2) an actor_group with zero actor_group_member rows — a team/department group created but never populated; (3) a role (tenant_custom) with zero rows in role_permission and zero in role_permission_group — a role that grants nothing, silently useless if assigned; (4) a tenant_user with no corresponding active role_assignment — a member who joined but was never actually given a role, meaning every permission check for them resolves to default-deny; (5) an invitation with status='accepted' but the resulting tenant_user row is missing or in an unexpected state (a data-integrity gap between the invitation lifecycle and its supposed side effect); (6) a service_account with no api_key rows at all (or none active) — a provisioned machine identity that can never authenticate. Detecting queries are straightforward LEFT JOIN / NOT EXISTS patterns against existing tables — no new schema needed. B5
D6. Decision-support Ruled out (mostly) No meaningful forecast/anomaly beyond what SoD detection and B5 gaps already cover. The one candidate — "this actor's permission footprint has grown unusually fast this month" (a privilege-creep trend) — could be built from identity_access_event (role_changed, permission_override_applied events) as a time-series count, but this is a thin variant of D3/D9 rather than a distinct forecast worth a dedicated capability. Ruled out as a named B6 instance for this module; the underlying signal already exists in identity_access_event if a future pass wants it. B6
D7. Autonomy boundary Applies — see full table below Every genuinely autonomous-capable action in IdentityService classified. A4, A13
D8. Evidence sources Ruled out (narrow) Identity has no B1d evidence-capture (photo/PDF/voice) surface — there is no "photograph a form and get a draft user record" flow. The nearest analog is SSO/SCIM auto-provisioning (sso_provider.auto_provision_users, SCIM user sync) where an external, trusted IdP is the "evidence source" creating identity_user+tenant_user rows without human confirmation — but this is deferred/post-v1 and the IdP is a pre-vetted trusted system, not untrusted evidence in the A11 sense. High-risk field if/when SCIM/SSO auto-provisioning is built: the role assigned on auto-provision (sso_provider.default_role_id) — an attacker who compromises or misconfigures the IdP could auto-provision a user with a privileged default role. This must match a tenant-approved default-role allowlist (deterministic check), not be trusted blindly from IdP claims. Ruled out as an active B1d instance today; flagged for when SSO/SCIM implementation is built (OPEN_ITEMS already tracks the build trigger). B1d, A11
D9. Reconciliation pairs Applies Pairs that should reconcile: (1) role_assignment (active, unexpired) ↔ actor's effective permission set — should always agree; a break is any cached/derived permission view drifting from live resolution (a caching-bug detector, not a business one, but real); (2) tenant_user.statusrole_assignment.status for that actor — a tenant_user with status='removed' should have no active role_assignment rows (currently role assignments are left as "history" per removeMember() — the doc explicitly says they remain but are "ineffective," which is fine functionally but means the reconciliation pair is "removed member ↔ any active-status role_assignment still marked active" — a hygiene/audit check, not a correctness bug, since actor.status gates effectiveness); (3) invitation.status='accepted' ↔ existence of the resulting tenant_user + identity_user rows (same as D5's gap #5, framed as reconciliation rather than negative-space); (4) access_request.status='approved' ↔ existence of the resulting role_assignment or user_permission_overrideapproveRequest() is supposed to atomically create both, so a mismatch indicates a partial-transaction failure; (5) support_access_grant.status ↔ presence of identity_access_event rows of type support_access_used during the grant window — a grant with zero usage events over its full active window is not a break, but a grant with usage events outside its starts_at/ends_at window would be a serious break (access used when it shouldn't have been possible) and should never occur if checkSupportAccess()'s double-check (DR-10) is implemented correctly — this pairing is effectively a self-audit of the enforcement path. Clean-match vs. break: (1) and (4) are structural invariants (break = bug, should auto-alert, not silently auto-resolve since they're identity-integrity signals); (2) and (3) are hygiene-tier, draft-only surfacing. B13
D10. Rollback Applies — see below See per-action reversal table under D7 (each row also states its rollback path since the two questions overlap heavily in this module). A12, B11, C8
D11. Adversarial/abuse Applies Untrusted-input attack surfaces: (a) invitation token guessing/replayinvitation.token_hash is bcrypt/SHA-256-hashed and never stored plaintext (good), but the acceptInvitation() flow is a classic target for token brute-forcing or reuse; deterministic validation already required: token must hash-match, status='pending', and now() < expires_at (all three already enforced per §6); (b) support-access-grant time-window bypass — an attacker (or buggy caller) checking only status='active' without the time-window check would grant access outside the approved window; DR-10's "both required" rule is the deterministic backstop, already documented as load-bearing; (c) API-key credential stuffingverifyApiKey() is a hot authentication path; the defense is key_prefix narrowing + hash comparison, already in place, but there's no visible rate-limiting/lockout schema for repeated failed API-key attempts (see gap below); (d) agent-as-actor privilege escalation — because agents are first-class actors sharing the exact same role_assignment/permission-resolution machinery as humans (§8), a compromised or misconfigured agent identity is not a lesser threat than a compromised human account; the mitigating control is role.requires_approval_for_agents, enforced in assignRole() as of 2026-07-06 (PROJECT_DECISIONS #21) — a gated role can no longer be granted to an agent without routing through human review first, closing the gap this walk originally flagged; (e) memory-poisoning bridge (per SCHEMA_DESIGN_RUNBOOK.md's Section 0/2.2.1 note) — identity has no agent/tenant-memory write surface itself (B12 tenant operating memory is a separate future subsystem), so there is no direct memory-poisoning attack surface inside identity's own tables today; however, agent_identity.config (jsonb) and agent_skill_assignment.config_override (jsonb) are exactly the kind of "an agent's own behavioral configuration, potentially agent-writable" fields the memory-poisoning bridge note warns about — if any future agent is ever allowed to write to its own config/config_override (self-modifying behavior), that write path must get the same trusted-source discipline A11 requires for documents/tools. Currently these fields are set only at provisionAgent()/admin-override time (assigned_by CHECK restricts to 'vrida_seed','provisioning','admin_override' — no 'agent_self' value exists), so the surface is closed today; flagged so it stays closed. Fraud/abuse patterns: mass invitation-spam to fish for valid tenant emails (mitigated by the pending-invite-per-email UNIQUE partial index, not a full rate-limit); SoD-violation "farming" (deliberately assembling toxic permission combinations across role+override layers to probe what's flagged) — detection already exists (never blocks, by design, so this is inherently observation-only, consistent with DR-19). Deterministic validations already in place: token hash-match + time-window (invitation, support-access), key hash+prefix match (API key), scope-consistency CHECK (override), cycle/cross-tenant CHECK (role inheritance). A11, A13
D12. Offline behavior Ruled out (mostly) Identity is a synchronous authorization dependency for every other module — there is no meaningful "offline capture, sync later" mode for permission checks themselves (a POS terminal offline still needs some cached permission answer, but that's a client-side caching/session-token concern, not a schema concept identity's tables need to model). The one queueable action: identity_access_event writes could in principle be buffered client-side and flushed on reconnect for a fully-offline POS terminal, but this is an application/client architecture decision, not something the identity schema constrains either way. Ruled out as a schema-level D12 finding. A2, A3, A10
D13. Channel sync Ruled out Identity has no external channel (e-commerce, marketplace, other POS) to stay in sync with — it is the authorization source of truth other channels read from, not a peer needing reconciliation. SSO/SCIM (§9) is the closest analog — syncing user/group state with an external IdP — but that is D8's evidence-source framing, not a channel-sync pair in B13's sense (SSO/SCIM users don't have a competing "other side" that can diverge the way an e-commerce order does). B13, A2
D14. Lifecycle/perishability Applies Things that age/expire/lapse: (1) role_assignment.ends_at — time-bounded assignments (seasonal staff) that lapse; signal = ends_at < now() while status='active'; action = permission resolution already excludes expired assignments (Step 1), but the row itself is never flipped to a terminal status — there's no sweep that sets status='revoked' or similar when ends_at passes, meaning "active" rows can be permanently, silently expired-but-labeled-active forever (see gap below); (2) invitation.expires_at — already swept by expireInvitations(); (3) access_request.expires_at — already swept by expireAccessRequests(); (4) support_access_grant.ends_at — already swept by expireSupportGrants(); (5) api_key.expires_at and rotation-age — swept by rotateExpiredApiKeys(), but only for tenants with tenant_security_policy.api_key_rotation_days set (no policy = no rotation ever, a real staleness risk with no detector); (6) identity_session idle timeout — swept by sweepIdleSessions(); (7) sod_violation.waiver_expires_at — checked at detection time (waiver stops suppressing once expired) but not proactively swept/alerted; (8) identity_user.last_login_at / tenant_user.last_login_at going stale — a dormant account that never logs in again is a lapsing-identity pattern (offboarding hygiene) with no detector today. Of these, (1) and (5)-without-policy are the two genuine gaps (see below); (2)-(4)/(6) are already fully handled by existing sweeps; (7)/(8) are hygiene-tier, not urgent. B5, B6, B13
D15. Capture modality Ruled out Identity is administrative CRUD (invite a user, assign a role, configure a security policy) — not a frontline capture surface. There is no scan/photo/voice modality relevant here; the "primary capture modality" is a form, full stop, and that is the correct and complete answer (not a gap). Offline fallback: none needed — identity admin actions are inherently online/console operations. B1, B1d, A2, A11

Capabilities Recorded

Applies:

  • B3 (self-maintaining master data) — role/group/override/API-key hygiene, all draft-only.
  • B4 (guided action / error prevention) — pre-commit SoD warning, orphaned-admin-access guard, redundant-request guard.
  • B5 (negative-space detection) — 6 gap patterns across agent skills, groups, roles, tenant_user, invitations, service accounts.
  • B9 (autonomous exception & approval queue) — access_request inbox and sod_violation list are the existing B9 surfaces for this module (approve/deny/waive already model B9's approve/reject/modify pattern).
  • B11 (consequence preview) — applies narrowly: role/permission changes have a "who gains/loses what" preview opportunity (e.g. "attaching this bundle grants 12 new permissions to 4 role holders") not currently modeled as a distinct feature but consistent with existing resolveEffectivePermissions().
  • B13 (continuous reconciliation) — 5 reconciliation pairs identified (D9).
  • A4/A13 (authority ladder / SoD) — see full D7 table below; this module is the implementation substrate for A4/A13 for every other module, and also has its own internal authority-ladder questions (agent role assignment, access-request approval).
  • A5 (agent identity & permission passport) — directly implemented: agent_identity + agent_skill_assignment + role_assignment on agent actors together already constitute a passport (scope = skills + permissions, owner = created_by_actor_id, kill switch = actor.status='deactivated').
  • A11 (adversarial defense) — token/credential/time-window attack surfaces (D11).
  • A12 (rollback contract) — every write has a defined reversal (D7/D10 table).
  • A14 (agent registry & fleet console) — identity's agent_identity table plus agent_skill_assignment is the exact substrate A14 is described as composing over ("shares substrate with A5"); this module doesn't build the console UI but owns the data A14 would read.

Ruled out (with reason):

  • B1/B1c/B1d (capture & routing & evidence) — identity is admin CRUD, not a capture-bar/evidence destination (D1, D2, D8, D15).
  • B2 (ambient analyst) — no module-specific "one sentence" surface identified beyond what B9's queue already communicates; would be redundant.
  • B6 (decision support/forecast) — no forecast distinct enough from D3/D9 signals to warrant a dedicated instance (D6).
  • B7 (tenant-config defaults) — not walked in depth; identity's tenant_security_policy and password_policy are themselves the tenant-config-defaults pattern for the security domain, but B7 as a capability (AI recommending config defaults) wasn't found to add anything beyond the existing schema.
  • B8 (outcome planner) — globally deferred (per the Plane's own tally); not evaluated for this module.
  • B12 (tenant operating memory) — identity has no tenant-memory table of its own; agent_identity.config/config_override are agent-instance config, not the cross-session "tenant operating memory" B12 describes. Ruled out; flagged only as an adjacent future surface under D11's memory-poisoning note.
  • B14–B20 (simulation, onboarding/import, outbound drafting, conversational query, ERP assistant, reporting views, personalized marketing) — none apply; identity has no simulation-worthy scenario, no bulk-import surface beyond invitations (already covered), no outbound customer-facing communication, and is not a reporting/marketing domain.
  • A2/A3/A10 (tier router, budget gate, runtime ops) — touched only at the boundary (D12); no module-specific finding beyond "identity has no offline-capture mode," which is a ruling-out, not a new requirement.
  • A6/A7/A8/A9 — not separately walked; D-question triggers didn't surface a module-specific gap in ledger/value-meter/data-boundary/eval-harness for identity beyond what's already true structurally (identity_access_event already serves as identity's A6-equivalent ledger for its own domain, as documented in §15).

D7 — Full Per-Action Autonomy-Boundary Table

Action (IdentityService method) Authority / boundary Rationale Reversal (D10)
provisionUser() needs-approval (webhook-triggered, not AI-initiated) Only called from the registration webhook path today — not an AI action at all currently, but if an AI onboarding flow (B15) ever drafts bulk user creation, it must land as draft-only, never auto-execute. Deactivate the resulting identity_user (soft path); no hard delete.
provisionServiceAccount() / provisionAgent() draft-only for AI-initiated requests; may-act-alone only for the Vrida provisioning service itself (a deterministic system process, not a discretionary AI action) Creating a new machine or agent identity is a privileged action; provisionAgent() doc already states "Only Vrida provisioning service may call this" — i.e. never an autonomous AI decision. Deactivate actor (status='deactivated'); no hard delete of agent_identity/service_account.
createCustomRole() / cloneFromTemplate() / updateRole() draft-only Defining what a role can do is identity-shaping; must be human-confirmed even if AI proposes the role (e.g. "clone the Cashier template and remove refund permission"). Deactivate role (is_active=false); role_permission changes are append-only delete+insert (DR-4), so the prior state is reconstructable from identity_access_event/history only if logged — no dedicated undo table (see gap below).
addPermissionToRole() / removePermissionFromRole() / attachBundle() / detachBundle() draft-only, and never above L3 even at high trust — this is exactly the "identity changes remain draft/approval-only" line B3 draws Changing what an existing, already-assigned role can do retroactively affects every current holder — the highest-blast-radius action in the module. Re-insert the prior role_permission row (delete+insert is reversible in principle since old rows aren't destroyed until the operation, but there is no automatic "undo last change" — reversal is manual re-application; also triggers detectSodViolations() for all holders as a safety net either direction.
assignRole() (to a human actor) draft-only → L4 submit-with-approval once the tenant has an access_request flow in active use; never may-act-alone for role.requires_approval_for_agents=true roles regardless of assignee Standard role grants to humans can reasonably rise to L4 (approval-gated auto-submit) as trust is earned (A4's "earned authority"); privileged roles must always route to approval. revokeRoleAssignment() — direct, clean reversal; role_assignment is designed exactly for this (status→'revoked', revoked_by_actor_id, revoked_at). Reversal window: unlimited (no time cutoff on revocation).
assignRole() (to an agent actor) needs-approval when role.requires_approval_for_agents=true; draft-only otherwise (never may-act-alone) This is precisely DR-30's "agent-elevation approval gate" — enforced 2026-07-06 (see PROJECT_DECISIONS #21): assignRole() now routes gated assignments through submitAccessRequest() and returns {status:'pending_approval', id: access_request.id} instead of assigning directly. Assigning power to an agent is inherently higher-risk than to a human (no independent judgment to catch a mistake before it acts). Same revokeRoleAssignment() path as human case, once the request is approved and the assignment exists; a still-pending request is reversed via withdrawAccessRequest()/denyAccessRequest() instead — no role_assignment row exists yet to revoke.
revokeRoleAssignment() may-act-alone (for the human who already holds the authority to manage that actor's access) — this is itself the "undo" action, not something needing its own second-layer approval in normal operation; needs-approval only in the edge case where revocation would remove a tenant's last admin (D4 item (c)) Revoking access is the safe direction; over-restricting is lower-risk than over-granting. Exception is the "orphan the tenant" edge case. No further reversal defined beyond re-assigning the role (which is itself a fresh assignRole() under the same rules above).
grantOverride() draft-only, same reasoning as role-permission changes — an override is a targeted authorization change Per-user overrides are identity-sensitive by definition. revokeOverride() — direct, clean, soft-delete.
revokeOverride() may-act-alone Same reasoning as role revocation — restricting access is the safe direction. Re-grantOverride() under normal draft-only rules.
createGroup() / updateGroup() / addMember() / removeMember() (actor_group) may-act-alone for addMember()/removeMember() on already-existing groups with already-existing roles (no new authority is created, just group roster); draft-only for createGroup() if the group will subsequently be role-assigned (since the group becomes an authority-bearing construct) Group membership itself isn't a permission grant unless the group has role assignments — the risk is concentrated at role_assignment.actor_group_id, not at actor_group_member. removeMember()/soft-delete actor_group — direct, clean, immediate.
sendInvitation() may-act-alone up to the invited role's own authority ceiling; needs-approval if the invited role_id is itself requires_approval_for_agents-flagged or an admin/owner-tier role (no such tenant-role-tier flag exists today for humans — see gap below) Sending an invitation is reversible (can be revoked before acceptance) and doesn't itself grant access until accepted — lower risk than direct assignment. revokeInvitation() — clean, before acceptance; after acceptance, must fall back to revokeRoleAssignment() + removeMember().
acceptInvitation() may-act-alone (this is the invitee's own self-service action, not an AI/agent decision) N/A — not an AI-plane action; included for completeness of the lifecycle. removeMember() + revokeRoleAssignment().
revokeInvitation() / expireInvitations() (sweep) may-act-alone Restricting/expiring is the safe direction; expireInvitations() is already a deterministic background sweep, not a discretionary AI action. Re-invite (fresh sendInvitation()).
grantSupportAccess() needs-approval — inherently a two-party action already (granted_by_actor_id must be a distinct Vrida staff member from support_actor_id per typical support workflows, though not DB-enforced as distinct) Platform-level access to any tenant's data is the highest-blast-radius action the whole schema defines. Should never be may-act-alone or draft-only-then-auto-submit even at high trust. revokeSupportAccess() — immediate; also natural expiry via ends_at.
revokeSupportAccess() may-act-alone Restricting the highest-risk access is always safe to do immediately. Re-grantSupportAccess() under the same needs-approval rule.
detectSodViolations() / dailySodSweep() may-act-alone (detection only, never blocks — DR-19) Detection-and-flag is explicitly non-blocking and read-only with respect to the underlying authorization state; the "action" is just writing a sod_violation row. N/A — no commit to reverse; resolveViolation()/waiveViolation() are the human-driven next steps, not reversals of detection itself.
acknowledgeViolation() / waiveViolation() / resolveViolation() needs-approval (a human compliance decision every time — never AI-executed) Waiving a flagged toxic-permission-combination is a compliance judgment call the plane's own text treats as inherently human (SoD is "advisory-only," but closing the advisory is a governance act). Re-open by re-detection on next sweep if the underlying condition still holds; resolvedopen is not a direct un-resolve — a fresh violation row would be created by the next detectSodViolations() run if still applicable.
submitAccessRequest() may-act-alone (self-service; the requester isn't granting themselves anything yet, just asking) Submitting a request creates no authority. withdrawRequest() — clean, requester-only.
approveRequest() needs-approval in the sense that this method IS the human-approval step — must never be AI-executed itself; this is the terminal human checkpoint the whole access_request flow exists to enforce This is literally the approval gate other actions route into; automating it would defeat its purpose. No direct reversal method; must fall through to revokeRoleAssignment()/revokeOverride() on the resulting grant.
denyRequest() / withdrawRequest() / expireAccessRequests() (sweep) may-act-alone Denying/withdrawing/expiring a request grants nothing; always the safe direction. Requester may submitAccessRequest() again.
createApiKey() draft-only (never may-act-alone for an AI/agent-initiated request) Issuing a new machine credential is equivalent in risk to granting a permission — should always be a confirmed human action, consistent with the "show once" design already treating key issuance as a deliberate, attended act. revokeApiKey() — immediate, clean.
revokeApiKey() / rotateExpiredApiKeys() (sweep) may-act-alone Revoking/rotating a credential is the safe direction; the sweep is already deterministic, not AI-discretionary. Issue a fresh key via createApiKey() (draft-only per above).
createSession() / endSession() / updateLastActive() / sweepIdleSessions() may-act-alone (not AI-plane actions at all — pure auth-flow mechanics) Included for completeness; these are deterministic infrastructure, never discretionary. revokeAllSessions() is itself the "undo" for a compromised session.
revokeAllSessions() may-act-alone for a human securing their own account or an admin responding to a confirmed incident; needs-approval if AI-initiated on a different user's account speculatively (e.g. an anomaly-detection agent deciding to force-logout someone) Forcing logout on your own session or a confirmed-compromised account is safe/expected; an AI agent unilaterally logging out another human based on a suspicion is a "never without human sign-off" action. No reversal needed — user simply logs back in; not a destructive action.
upsertSecurityPolicy() / getEffectiveMfaRequired() draft-only for upsertSecurityPolicy() (changing a tenant's security floor is identity-shaping); getEffectiveMfaRequired() is a pure read, N/A Security policy changes affect every user in the tenant at once. Re-upsertSecurityPolicy() to the prior values (values aren't versioned — see gap below, low urgency).

Never-allowed (across all actions, regardless of trust level earned): AI may never (1) approve its own or its delegating human's access_request (this would violate A13 exactly as A13's text describes — "AI can flag a bank change but never approve it," the identity-domain equivalent being "AI can draft a role change but never approve its own elevation"); (2) grant itself or another agent a role.requires_approval_for_agents=true role without routing through access_request; (3) grant or modify support_access_grant (platform-tenant-boundary crossing is always human-only); (4) directly mutate password_policy (global Vrida-wide floor, not tenant-configurable at all, let alone AI-configurable).

Real Gaps Found

Gap 1 — CLOSED 2026-07-06. Agent-elevation approval gate has no service-layer enforcement. role.requires_approval_for_agents exists (DR-30, 2026-07-06 backfill); assignRole() now branches on it (see PROJECT_DECISIONS #21) — an agent assignee plus a gated role routes through submitAccessRequest() and returns {status:'pending_approval', id} instead of assigning directly. This D7 walk's "needs-approval" classifications for agent role-elevation are now real, not aspirational, and identity has a working precedent other product modules (e.g. crm) can point to for "how does an agent get elevated privilege safely."

Gap 2 — No terminal status transition when a time-bounded role_assignment lapses (D14). role_assignment.ends_at passing is correctly excluded from permission resolution (Step 1's now() < ends_at check), but the row's status column is never flipped away from 'active' when ends_at passes — unlike invitation/access_request/support_access_grant/api_key, which all have dedicated expiry sweeps that set a terminal status. This means an admin looking at "active role assignments" for audit purposes sees rows that are functionally expired but administratively still labeled active, and there is no identity_access_event written when a time-bounded assignment lapses (contrast: revocation writes role_changed). Missing: no new column needed — the schema already has ends_at and status; what's missing is a background sweep method (sweepExpiredRoleAssignments(), analogous to expireInvitations()/expireSupportGrants()) that sets status to a new terminal value and writes an identity_access_event. This would require adding one new value to role_assignment.status's CHECK constraint (currently 'active','revoked' only — would need e.g. 'expired') — a small, additive schema change, not a redesign. Urgency: deferrable — functionally harmless today (resolution already excludes expired rows correctly), it is an audit-clarity/hygiene gap, not a security hole.

Gap 3 — No rate-limiting/lockout schema for repeated authentication failures (D11). identity_access_event records login_failed/mfa_failed events, so the evidence exists to build a failed-attempt detector, but there is no dedicated counter, threshold, or lockout-state column anywhere in identity (no failed_attempt_count, no locked_until on identity_user or actor). The account_locked/account_unlocked event types exist in identity_access_event's CHECK, implying lockout is an intended feature, but nothing in the schema tracks the state that would trigger writing those events other than ad hoc counting over the event log at read time. Missing: likely a small addition to identity_user (or a new lightweight table) — e.g. failed_login_count integer, locked_until timestamptz — or, alternatively, this could legitimately live entirely in Supabase Auth's own domain (credentials/lockout are explicitly Supabase Auth's territory per §Purpose) in which case this is not identity's gap at all. Urgency: deferrable — needs a design decision (Supabase Auth's built-in lockout vs. an identity-side counter) before it's even clear this is a real gap or a non-issue; flagging for that decision rather than asserting a fix.

Gap 4 — No versioned history for tenant_security_policy / role_permission bundle-level changes beyond the append-only role_permission row itself (D10). Reversal of a security-policy change or a role's cumulative permission state relies on identity_access_event.metadata (jsonb, unstructured) if the caller happened to log the prior values — there's no guaranteed structured "before" snapshot the way sod_violation.decision_snapshot now provides for SoD detection. Missing: nothing added here — noting that decision_snapshot's pattern (jsonb, nullable, deliberately-deferred key shape) is the right template if this is ever prioritized, but no table/column is proposed now. Urgency: deferrable — low-frequency actions (security policy changes are rare), and the existing append-only role_permission pattern plus identity_access_event already provides a forensic trail sufficient for manual reconstruction; only worth a dedicated column if reversal needs to become one-click/automatic.

No gaps were found that block another module's AI-plane pass from having a working precedent to follow. Gap 1 is now closed (2026-07-06, PROJECT_DECISIONS #21) — identity has a working, tested precedent for agent-elevation approval gating that other product modules can follow.

21. Header/Line Remediation reopen (2026-07-10) — fix #3

identity was reopened a 4th time as the fourth module of the second batch of the coordinated "Header/Line Remediation" effort (vrida-header-line-remediation-design-2026-07-10.md, based on vrida-header-line-pattern-audit-2026-07-10.md), already run through an independent adversarial verification pass before any of it was built. Inventory landed first in this batch (fixes #5/#9, PROJECT_DECISIONS #49); Orders second (fix #6, PROJECT_DECISIONS #50); Billing third (fix #2, PROJECT_DECISIONS #51); identity is fourth. A separate, not-yet-docs'd "bare-FK fixes" pass touches other modules under this same effort but is out of scope here. See §9/§14 DR-36 for the full design rationale.

What was built:

  • New table identity.invitation_site_assignment (6 cols): id, tenant_id, invitation_id (composite FK → identity.invitation(id, tenant_id)), site_id (NOT NULL, deliberately no FK — see DR-36), site_role_id (nullable FK → identity.role), created_at. Carries its own UNIQUE(id, tenant_id).
  • New column identity.user_site_assignment.created_from_invitation_site_assignment_id (nullable, composite FK → identity.invitation_site_assignment(id, tenant_id)).
  • Prerequisite UNIQUE(id, tenant_id) added to identity.invitation.
  • identity.invitation.site_assignments (JSONB) deprecated in place — comment only.
  • Column-count impact: +1 table / +6 cols + +1 col → 37 tables / 422 cols → 38 tables / 429 cols.

Pre-migration audits (mandatory, both — this is Identity's lowest-necessity/most-scrutinized reopen in the whole batch, so both are documented carefully rather than glossed over):

  1. JSONB inspection: identity.invitation has ZERO rows in this environment — confirmed live before the build. No real site_assignments shape existed to inspect, and there was nothing to backfill (the post-backfill count-reconciliation step this fix's design calls for is trivially 0=0).
  2. Mandatory orphan-check audit: found exactly ONE live orphaned row in identity.user_site_assignment — a site_id value with no matching row in multi_loc.site (id 4cd177f7-6f3a-42aa-a977-6fe0b34e89e6, site_id 3587ced4-62ba-47a8-b5ba-4cb0ae43ce29). identity.tenant_user.default_site_id has zero non-null rows — zero risk there. Because of this orphan, the optional opportunistic bundle the design doc suggested (wiring user_site_assignment.site_id, tenant_user.default_site_id, AND this fix's own new site_id all to real multi_loc.site FKs, since it would be cheaper than 3 separate future reopens) was deliberately NOT taken in this build — explicitly flagged for a human decision (null out the orphan vs. correct the reference), not unilaterally resolved. This must be read as an open decision, not a completed action — see docs/open-items/OPEN_ITEMS.md and PROJECT_DECISIONS #52.

Independent verification (separate agent, adversarial, live-DB-checked) — pasted, attributed. Described in its own report as checking "exactly the kind of reopen where scope creep or a rushed shortcut is most likely to slip through unnoticed," given this is Identity's weakest-justified 4th reopen. Findings: PASS on every single check, zero findings of concern. Schema shape confirmed exactly as claimed (including confirming site_id genuinely has no FK at all, not accidentally added or accidentally missing something else). Both composite FKs confirmed genuinely 2-column. Both pre-migration audits independently re-verified live (the exact same orphan row ID/site_id reconfirmed, the exact zero-count on tenant_user reconfirmed). The "optional bundle was NOT built" claim was independently verified directly (queried all 28 FKs referencing multi_loc.site codebase-wide — none touch any of the 3 columns in question). All 5 live-reproduction scenarios independently reproduced in a fresh two-tenant fixture built entirely from scratch (not reusing any build-time IDs). Grants match the established sibling-table pattern exactly. The deprecation comment was confirmed to have landed on the right column with the column itself otherwise unchanged. A full scope-discipline review of the entire migration file's DDL inventory found zero scope creep — the migration touches exactly what fix #3 claims and nothing more. The only gap noted: no regression tests existed AT THE TIME of that verification pass (since resolved — see Tests below).

Tests: apps/api/src/identity/__tests__/identity-governance.spec.ts — new Group E (5 tests, E1–E5) covering: valid same-tenant invitation_site_assignment insert; cross-tenant invitation_id rejected; valid same-tenant created_from_invitation_site_assignment_id on user_site_assignment; cross-tenant rejected; NULL succeeds. File went from 21→26 tests.

Migration: packages/db/migrations/20260710060000_headerline_identity_fix3.sql. Schema files: packages/db/src/schema/identity/governance.ts (new invitationSiteAssignment export; invitation gains unique('invitation_id_tenant_id_unique')), packages/db/src/schema/identity/assignment.ts (userSiteAssignment gains created_from_invitation_site_assignment_id + its composite FK). See PROJECT_DECISIONS #52.

Follow-up (2026-07-10, same day) — DR-37, Identity's 5th reopen, resolves the human decision above. The orphaned row was investigated: its tenant (5312b5df-c9c3-4099-9733-a93de5fc8517) had exactly 5 rows total anywhere in the schema, all boilerplate/system rows, zero business data — confirmed isolated dev-seed fixture junk via a DB-wide tenant-isolation scan, not just the original claim. DELETED. All 3 columns in the bundle — user_site_assignment.site_id, tenant_user.default_site_id, invitation_site_assignment.site_id — are now real composite FKs → multi_loc.site(id, tenant_id); multi_loc.site gained the prerequisite UNIQUE(id, tenant_id) (multi_loc's 1st reopen since its 2026-06-29 lock).

Independent verification (separate agent, adversarial, live-DB-checked) — pasted, attributed. Overall verdict: CLEAN — zero findings. Gave particular scrutiny to the irreversible DELETE, independently re-verifying tenant isolation via its own DB-wide tenant_id scan rather than trusting the claimed counts. All 4 new constraints (1 UNIQUE + 3 composite FKs) confirmed correctly shaped via pg_constraint, no leftover bare duplicates. Both platform.tenant.primary_site_id and identity.user_permission_override.scope_id independently confirmed to still carry zero FK — genuinely untouched. All 9 live-reproduction scenarios (same-tenant/cross-tenant/orphan × 3 columns) independently reconstructed from scratch and reproduced exactly as claimed, including one built specifically to isolate the new invitation_site_assignment_site_tenant_fkey from the pre-existing invitation_id FK. Full suite 873/873 on the first run; a second run reproduced the known, pre-existing admin-tenants.spec.ts pagination race (documented since 2026-07-08) — re-ran in isolation, clean, confirming it as the same known flake, not a new regression. Grants on all 4 touched tables confirmed unchanged. One bonus finding beyond the task's explicit ask: CROSS_MODULE_CONTRACTS.md also needed the same correction as OPEN_ITEMS — folded into this pass's own docs update, which also surfaced a previously-untracked 4th deferred sibling, identity.access_request.requested_scope_id (same shape/target, never named in DR-36's bundle or this verification's own checklist).

Tests: permission-engine.spec.ts and identity-governance.spec.ts (Group E) both required new insertSite() helpers, since both had used unenforced randomUUID() placeholders for site_id — 36/36 passing.

Migration: packages/db/migrations/20260710080000_headerline_multiloc_site_fk_wiring.sql. Schema files: packages/db/src/schema/multi_loc/site.ts (new unique('site_id_tenant_id_unique')), packages/db/src/schema/identity/assignment.ts, packages/db/src/schema/identity/membership.ts, packages/db/src/schema/identity/governance.ts (all 3 retargeted from bare comment to composite FK). See PROJECT_DECISIONS #54.

Last modified: Jul 12, 2026, 6:34 AM PT
On this page
Esc