Action policies
Protecting operations — actions, their targets, and the permission policies behind every granted or denied decision.
Action policies are the half of the access model that decides whether an operation may happen. This page covers the action-specific pieces: how operations become actions, how targets scope them, and how a permission policy turns into a granted or denied decision at an enforcement point. The shared anatomy — the formula language, permit-only semantics, evaluation — is defined in the Access model and holds here unchanged.
Actions
An action is one protected operation of your application, named by a service:name code. The seeded demo ships four: invoices:view, invoices:edit, invoices:issue, and admin:assignUserRole. The code is what the application asks about and what policies scope to. Beyond the code and a title, an action carries two things policies build on:
- Tags — slugs that categorize the action, readable in formulas as the
action.tagsarray. Tags are how targets cover groups of related operations without enumerating codes. - A request schema — the shape of
request.*for this action, declared as JSON Schema. An action with no schema simply offers no request attributes to reason about.
Action targets
A target decides which actions a policy covers. The three modes, each present in the seeded demo:
- INDIVIDUAL — exactly one action, by code. The demo's Edit invoice target scopes
invoices:edit. - CUSTOM — a group selected by a condition over action attributes. The demo shows both common idioms: by tag,
any action.tags where it = "billing"(Billing operations), and by code set,action.code in ["invoices:view", "invoices:edit"](View and edit invoices). - ALL — every action in the workspace. The demo's Admin access target.
A target condition parses in its own context and sees action.* only — referencing user.* there is rejected at save time. Which users get access is the policy's question, not the target's.
Where request.* comes from
Policies read request.* through the schema their target provides:
| Target mode | request.* schema |
|---|---|
| INDIVIDUAL | the action's own request schema |
| CUSTOM | declared on the target — the group's shared interface |
| ALL | not available — no request shape is common to every action |
A CUSTOM target's schema is conceptually the intersection of its members' request shapes — the attributes every action in the group supplies, which is what lets one policy rely on them across the whole group. The demo's Billing operations target declares { invoiceId: uuid } for its group, and that is exactly what its ownership policy reads as request.invoiceId. Parse-time validation enforces the table above: under an ALL target, a condition referencing request.* is rejected with "request.* is not available in this parsing context".
Permission policies
A permission policy grants its target's actions while its condition holds. The seeded policies cover the whole range of condition styles:
| Policy | Target | Condition |
|---|---|---|
| Admin access | Admin access (ALL) | user.role = "admin" |
| Managers handle invoices | View and edit invoices | user.role = "manager" |
| Finance handles billing | Billing operations | user.department = "finance" |
| Accounting edits invoices | Edit invoice | user.department = "accounting" |
| Invoice owner access | Billing operations | exists invoice where data.id = request.invoiceId and data.owner_id = user.id |
Three things are worth reading out of this table. Role is just an attribute — two of the five policies never mention it. Ownership is one line — a dataset reference looked up at decision time, not copied into the request. And Accounting edits invoices was authored by a coding agent in the Coding agents walkthrough — a policy is a policy, whoever writes it.
Permission policies yield exactly one thing: a granted or denied decision. Constraining which rows an operation may touch is the other half of the model — Data policies — and it composes with this one instead of complicating it.
The decision
An authorization request carries the action code plus the user, context, and request attributes. Evaluation follows the shared semantics: every ENABLED policy whose target covers the action is a candidate, each condition evaluates with the request's values substituted in, and the decision is granted when at least one condition is true.
arkveil eval explain -a invoices:edit --user '{"id":"u-77","department":"accounting"}'action: invoices:edit
granted: GRANTED
granting policies: 96368751-2b52-44ec-a90b-adbf7eea0c73
candidate policies: 5Five candidates covered invoices:edit — through the ALL target, the code-set target, and the INDIVIDUAL one — and one condition granted.
Enforcement
Your application marks its enforcement points and never restates the rules. In NestJS that is one decorator:
@Patch(":id")
@PermissionPoint("invoices:edit")
edit(@Param("id") id: string) { … }The guard sends the decision request, lets a granted request through, and turns denied into a 403. The SDK is fail-closed: a network failure, a timeout, or an unexpected response is treated as denied — a degraded connection narrows access, it does not widen it.
The full integration surface — Express and Fastify middleware, direct checkPermission calls, typed permission codes — lives in the SDK reference. Getting started walks the whole path against the seeded demo.
Next steps
-
Access model — the shared anatomy this page builds on.
-
SDK — enforcement in your application, in depth.
-
Coding agents — a permission policy authored end to end by an agent.
-
Policy tests — pin the granted and the denied while policies evolve.