> ## Documentation Index
> Fetch the complete documentation index at: https://docs.alfiz.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Configuring Access Request Approval Policies in Alfiz

> Approval policies define who approves access requests via auto-approval predicates, named approver roles, or management-layer routing, and in what order.

An approval policy is a sequence of ordered stages attached to a requestable role or scope type. Each stage either evaluates automatically or waits for a human decision. Stages are processed in order: the first stage runs at submission, subsequent stages unlock one at a time as each preceding stage approves. A denial at any stage is final and writes nothing. Because a policy stores **stages** and never resolved approver identities, it survives org restructuring and hierarchy promotion without modification.

## The `ApprovalPolicyInput` type

```typescript theme={null}
interface ApprovalPolicyInput {
  /** Ordered stages; every non-auto stage requires an explicit decision. */
  stages: readonly ApprovalStage[];
}

type ApprovalStage =
  | { kind: "auto"; predicate: AutoApprovalPredicate }
  | { kind: "named_approvers"; roleId: string }
  | { kind: "management"; layers?: number };
```

You supply an `ApprovalPolicyInput` wherever a requestability declaration expects a `policy` field, either on a scope type in the catalog or inside a role's `requestable` block.

## Stage types

### Auto-approval predicates

An `auto` stage evaluates a condition against the requester's subject closure, using the same `can()` machinery that powers every check in Alfiz. There is no separate rules engine. If the predicate passes, the stage is recorded as auto-approved and the workflow immediately advances to the next stage. If it fails, the stage abstains without recording a decision, and the workflow advances to the next stage. Auto stages accelerate workflows; they never deny.

<Warning>
  With one exception: a failing auto stage that is the **last** stage has nothing to advance to. Since 0.7.0 such a request is **auto-denied**, and the decision is recorded with `decidedBy: "auto"` and the requester can re-request when circumstances change. (Before 0.7.0 it stayed `pending` at a stage nobody could decide, resolvable only by administrative override, which is the failure mode the change removes.) An auto-final policy is therefore the *instant-decision* shape: predicate met approves, predicate unmet denies. If unmet predicates should escalate to a human instead, put a `named_approvers` or `management` stage after the auto stage.
</Warning>

```typescript theme={null}
type AutoApprovalPredicate =
  | { type: "in_group"; groupId: string }
  | { type: "in_org"; orgId: string }
  | { type: "member_of"; subject: SubjectId }
  /** The requester's effective access already intersects this pattern. */
  | { type: "holds_pattern"; pattern: PermissionPattern };
```

<ParamField path="in_group" type="{ type: &#x22;in_group&#x22;; groupId: string }">
  Passes when the requester is a member of the given group (directly or through group inheritance).
</ParamField>

<ParamField path="in_org" type="{ type: &#x22;in_org&#x22;; orgId: string }">
  Passes when the requester belongs to the given organization in the directory.
</ParamField>

<ParamField path="member_of" type="{ type: &#x22;member_of&#x22;; subject: SubjectId }">
  Passes when the requester's subject closure contains the given subject. Works with any valid subject including implicit groups like `directs:<uid>` or `orgof:<uid>`, for example `{ type: "member_of", subject: "directs:team-lead-uid" }` auto-approves everyone who reports directly to a particular manager.
</ParamField>

<ParamField path="holds_pattern" type="{ type: &#x22;holds_pattern&#x22;; pattern: PermissionPattern }">
  Passes when the requester's effective access already intersects the given pattern. Grounded in the catalog's concrete keys; a fully-revoked requester never passes, and a pattern matching no catalog key never passes. Negative-always-wins holds here exactly as it does in `can()`.
</ParamField>

### Named approvers

A `named_approvers` stage requires approval from any subject who holds an unexpired grant of the designated role, either directly or through any closure member.

```typescript theme={null}
{ kind: "named_approvers"; roleId: string }
```

The canonical use is expressing the application owner as a role on the namespace. Rather than naming a specific person, you name the role: whoever holds it at evaluation time is the approver. This keeps the policy stable across ownership changes.

```typescript theme={null}
{ kind: "named_approvers", roleId: "content-owner" }
```

<Note>
  To approve a `named_approvers` stage, a user must hold an **unexpired** grant of the designated role, either on their own subject or through a group or org they belong to. Expired grants do not confer approval authority.
</Note>

### Management layers

A `management` stage routes the request to the requester's manager, or `layers` transitive managers upward in the reporting hierarchy. The chain is resolved by walking reporting edges at evaluation time.

```typescript theme={null}
{ kind: "management"; layers?: number }
```

`layers` defaults to `1`, which means the requester's direct manager. `layers: 2` means the manager's manager, and so on. Where the reporting chain is shorter than `layers`, the topmost manager approves.

```typescript theme={null}
// Direct manager
{ kind: "management" }
// or equivalently:
{ kind: "management", layers: 1 }

// Skip-level: the manager's manager
{ kind: "management", layers: 2 }
```

<Warning>
  A policy with a `management` stage is a **configuration error** when no reporting hierarchy is populated in the provider. Alfiz surfaces this at policy attachment time (for roles) and at request submission time (for catalog scope types), and never silently skipped. Populate reporting edges via `setReportingEdge` or `importDirectory` before attaching management-layer policies.
</Warning>

Because stages reference a layer count rather than a resolved person, they survive org-root promotion untouched. A pending request that was created with `"2 layers up"` still means exactly that after a hierarchy reorganization: the new two-layers-up manager becomes the approver at their next evaluation.

## Policy attachment

<Tabs>
  <Tab title="On a role">
    Attach an approval policy directly to a role's `requestable` block using `createRole`. The policy applies to every request for that role, regardless of scope.

    ```typescript theme={null}
    await app.createRole(
      {
        name: "Docs Editor",
        patterns: ["docs.files.*"],
        requestable: {
          prompts: [
            {
              id: "reason",
              label: "Why do you need this access?",
              required: true,
            },
          ],
          stages: [
            // Stage 0: auto-approve members of the trusted-editors group
            {
              kind: "auto",
              predicate: { type: "in_group", groupId: "trusted-editors" },
            },
            // Stage 1 (fallthrough): content owner decides
            { kind: "named_approvers", roleId: "content-owner" },
          ],
        },
      },
      { kind: "admin", actorUserId: "root" },
    );
    ```
  </Tab>

  <Tab title="On a scope type">
    Attach an approval policy to a scope type's `requestable` block inside the catalog. The policy applies to every request whose scope is an instance of that type.

    ```typescript theme={null}
    defineCatalog({
      namespaces: ["docs"],
      permissions: { /* ... */ },
      scopeTypes: {
        "docs.folder": {
          parent: null,
          requestable: {
            prompts: [
              {
                id: "reason",
                label: "Why do you need access to this folder?",
                required: true,
              },
            ],
            requireExpiry: true,
            maxDurationMs: 30 * 24 * 60 * 60 * 1000, // 30 days
            policy: {
              stages: [
                { kind: "management", layers: 1 },
              ],
            },
          },
        },
      },
    });
    ```
  </Tab>
</Tabs>

## A complete three-stage policy example

This policy uses all three stage types in order: first try to auto-approve, then fall through to a named application owner, and finally escalate to a skip-level manager.

```typescript theme={null}
await app.createRole(
  {
    name: "Project Admin",
    patterns: ["projects.admin.*"],
    requestable: {
      prompts: [
        {
          id: "project_id",
          label: "Which project do you need admin access for?",
          required: true,
        },
        {
          id: "justification",
          label: "Why do you need admin access?",
          required: true,
        },
        {
          id: "duration",
          label: "How long do you need it?",
          kind: "select",
          options: ["1 week", "1 month", "Permanent"],
          required: true,
        },
      ],
      stages: [
        // Stage 0: auto-approve if the requester already holds a covering pattern
        // (e.g. they are an existing admin on a parent project)
        {
          kind: "auto",
          predicate: {
            type: "holds_pattern",
            pattern: "projects.admin.*",
          },
        },
        // Stage 1: any subject holding the "project-owner" role may approve
        {
          kind: "named_approvers",
          roleId: "project-owner",
        },
        // Stage 2: the requester's manager's manager (skip-level) also approves
        {
          kind: "management",
          layers: 2,
        },
      ],
    },
  },
  { kind: "admin", actorUserId: "root" },
);
```

In this flow:

1. **Stage 0** runs immediately at submission. If the requester's effective access already includes `projects.admin.*`, the stage auto-approves and the workflow advances to stage 1. If it does not, the stage abstains and the workflow still advances to stage 1, because the auto stage never blocks.
2. **Stage 1** shows the request to any user holding the `project-owner` role, who sees this request in their approver queue and may approve or deny it.
3. **Stage 2** starts only after stage 1 approves, when the skip-level manager receive the request. Their approval writes the grant row.

## Requestability does not inherit

`requestable` exists in exactly two places: on a scope type (`ScopeTypeInput.requestable`, whose policy lives under a `policy` key) and on a role (`RoleInput.requestable`, whose stages sit directly on the block). There is no third place, and there is no inheritance of any kind.

A policy on `docs.folder` does **not** propagate to `docs.doc`, even though documents nest inside folders. Each scope type declares its own `requestable` block or is not requestable at all. Nothing is requestable by default.

<Note>
  A requestable scope type must declare at least one approval stage. A `policy` with an empty `stages` array is a validation error, surfaced by `lintCatalog` at build time and by `assertPolicyResolvable` at request submission.
</Note>

## Just-in-time access

The canonical just-in-time (JIT) access pattern combines three features:

1. **`requireExpiry: true`** on the requestable role or scope type, so every request must propose an expiry; open-ended requests are rejected.
2. **`proposedExpiresAt`** in the request, where the requester proposes how long they need the access; the `maxDurationMs` cap enforces an upper bound.
3. **`can.fresh()`** at enforcement points, which bypasses the subject-side cache so the grant expiry is evaluated at exactly the moment of the check, not against a potentially stale snapshot.

```typescript theme={null}
// Declare the scope type as JIT-only (max 8 hours, expiry required)
scopeTypes: {
  "infra.environment": {
    parent: null,
    requestable: {
      requireExpiry: true,
      maxDurationMs: 8 * 60 * 60 * 1000, // 8 hours
      prompts: [
        { id: "incident", label: "Incident or change ticket ID", required: true },
      ],
      policy: {
        stages: [{ kind: "management", layers: 1 }],
      },
    },
  },
}

// After the request is approved and the grant row is written:
await alfiz.can.fresh(
  { userId },
  "infra.environment.deploy",
  "infra.environment:prod",
);
// Returns false the moment the grant's expiresAt passes.
// no cache TTL delay on a destructive surface.
```

<Tip>
  `can.fresh` is the right choice for any destructive action that may have been time-limited by an approved request. It is the only check shape guaranteed to reflect expiry at the instant of evaluation rather than at the last cache fill.
</Tip>
