(requester, role-or-pattern, scope, expiry?), that sits in a pending workflow until every approval stage clears. When the final stage approves, that approval is the act of writing the grant row. Denial writes nothing. The request system adds no new access semantics; it is a workflow that gates row creation.
What a request is
Every access request is anAccessRequest object with a proposed grant payload, a justification record, a snapshot of the approval stages, and a workflow state:
"2 layers up") and role ids rather than named individuals, a pending request survives org restructuring and org-root promotion without modification. The actual approver identity resolves fresh at evaluation time.
Declaring requestability in the catalog
Nothing is requestable by default. You opt in at the catalog level, either on a role or on a scope type. A role can carry arequestable block; a scope type declares requestable inside ScopeTypeInput.
Global access (scope
*) is always requested through requestable roles, not patterns. Pattern requests require a scope whose type declares requestable, because the requestability declaration lives on the scope type, not the permission itself.Submitting a request
Users submit requests throughapp.submitRequest. Supply a requesterUserId, exactly one of roleId or pattern, an optional scope, a justification map (prompt id → answer), and an optional proposedExpiresAt for time-bound access.
approved and the grant row is written before submitRequest returns.
Request workflow states
pending
pending
The request has been submitted and is waiting for a human decision on the current stage. The
stageIndex field indicates which stage is active.approved (auto-approved)
approved (auto-approved)
Every stage passed. If all stages were
auto predicates and every predicate evaluated to true, the request resolves without any human decision. The grant row is written atomically with the state transition.approved
approved
A human approver advanced the final stage. The grant row is written as part of that decision. Approval is row creation, with full provenance linking the row back to the request.
denied
denied
Any approver at any stage denied the request. No grant row is written. Denial at any stage is final.
Time-bound requests and just-in-time access
A request may carry aproposedExpiresAt timestamp. When approved, the resulting grant row carries that expiry. This pairs naturally with can.fresh on destructive or sensitive surfaces, since can.fresh bypasses the subject-side cache and evaluates the grant’s expiry at the moment of the check, so temporary elevation takes effect and expires precisely.
requireExpiry: truemeans every request to this role or scope type must propose an expiry; Alfiz rejects open-ended requests.maxDurationMscaps how far into the futureproposedExpiresAtmay be, even ifrequireExpiryis not set.
Where requests live
Requests are homed where the resulting grant row would live:- Global-scope requests (role requests with no scope, or with scope
*) run on the org root Application. If your Application is not the org root, submitting a global-scope request is rejected. - Instance-scoped requests (pattern requests targeting
scopeType:id) run on the Application that owns that scope.
AccessRequest shape is identical against every provider, so workflows deepen without migration.
Approver operations
1
List your queue
Call
listApproverQueue(approverUserId) to fetch every pending request the given user may currently decide, based on their role grants and position in the reporting hierarchy.There is a third source of eligibility: an administrative override. A holder of alfiz_internal.requests.decide_request at the global scope may decide any stage, and those requests appear in their queue too. It is the escape hatch for a stage nobody can fill, such as a named_approvers stage whose role has no holders, or a management stage for a user with no manager.2
Decide a request
Call Alfiz validates that
decideRequest with the request id, a deciderUserId, and a decision of "approved" or "denied". Optionally attach a note.deciderUserId is entitled to decide the current stage before applying the decision. Approving the final stage triggers the grant write atomically.3
Cancel a request
The requester may cancel a pending request at any time:Cancelled requests do not appear in approver queues. Alfiz cancels pending requests automatically on the two deletion paths, each narrowly:
deleteSubject on a user: subject cancels the requests that user filed, and deleteScope cancels the pending requests targeting that scope. Deleting a group: or service: subject cancels nothing, because neither files requests.Errors on the request path
Every request write rejects throughProviderWriteRejectedError, whose code tells you how to respond:
Approver entitlement is resolved at decision time rather than when the request was filed. The manager chain and role membership are read fresh. A reorg between submission and decision routes the request correctly.
@alfiz/core also exports RequestStateError, thrown by the pure applyDecision helper when a decision is applied to a non-pending request. You only see it if you drive the state machine yourself; the Application converts the same condition into the conflict rejection above.
Building the request surfaces
Alfiz ships no request UI. What it ships is everything the UI needs, on the Application, so a request form and an approver queue are each a thin render over one call:submitRequest validates the justification against the declared prompts server-side regardless of what your form did, so a hand-rolled client cannot skip a required answer.
For the role editor and grant picker that sit alongside these,
@alfiz/core ships the wildcard-aware permission tree (buildPermissionTree, toggleNode, isNodeChecked, isNodeIndeterminate, and nodePattern. It is selection logic and state, not markup: whole-group selection stores the <group>.* pattern, which is what makes forward-inclusion real rather than a snapshot.