Grants and approval thresholds
A grant gives one agent named operations on one service, for your tenant, until an expiry. Rules on the grant (and in project and enterprise policies) can only restrict: they deny, or they require approval. Nothing in a rule widens a grant. See Tenants and grants and Policy layers.
Console: Access and delegation at /app/tenants/:tid/access. You need grant.write (tenant_admin or org_admin).
Create a grant
POST /v1/tenants/{tenant_id}/grants
content-type: application/json
{
"agent_id": "agt_…",
"service_id": "svc_…",
"operations": ["billing.refund.create", "billing.invoice.read"],
"resources": [{ "type": "invoice", "id": "*" }],
"rules": [ … ],
"delegable": false,
"expires_at": "2026-12-31T00:00:00Z"
}| Field | Notes |
|---|---|
operations | At least one. Glob patterns over dotted names: * matches within one segment, ** matches anything. |
resources | Type and ID globs, e.g. { "type": "invoice", "id": "4*" }. An empty list means any resource of the operation's type. |
rules | Restrictions, validated as an axiom.policy.v1 rule list (see below). |
delegable | Allows the agent to hand a narrower child grant to another agent, one hop only (Explicit delegation). |
expires_at | Required. Resume deadlines for approved actions never run past it. |
The agent must belong to the tenant, and the service must have admitted the tenant (see admitting agents).
Revoke a grant with POST /v1/grants/{grant_id}/revoke and { "reason": "…" }. Every descendant (delegated child) grant is revoked with it.
Thresholds and ceilings
The sample project's grant shows the pattern from the launch demonstration: routine refunds up to USD 500 run within the standing grant, larger ones need a finance approver, and nothing above USD 100,000 can run even if someone offers to approve it.
[
{
"id": "finance-approval-over-500",
"description": "Refunds above USD 500 need a finance approver",
"effect": "require_approval",
"operations": ["billing.refund.create"],
"when": [{ "field": "params.amount", "op": "gt", "value": "500" }],
"reason_code": "finance_approval_required",
"approval": {
"approver_role": "finance_approver",
"count": 1,
"separation_of_duties": true,
"decision_ttl_seconds": 14400,
"resume_ttl_seconds": 3600
}
},
{
"id": "refund-ceiling",
"description": "No refund above USD 100,000, even with approval",
"effect": "deny",
"hard": true,
"operations": ["billing.refund.create"],
"when": [{ "field": "params.amount", "op": "gt", "value": "100000" }],
"reason_code": "ceiling_exceeded"
}
]Rule fields
| Field | Meaning |
|---|---|
id | Unique within the rule list. Overrides refer to it. |
effect | deny or require_approval. (allow is accepted but changes nothing: grants authorise, rules restrict.) |
operations, tenants, environments | Optional glob filters; omitted means every value. |
when | All conditions must hold. field is a path such as params.amount, resource.id, agent.verification. |
op | eq, ne, gt, gte, lt, lte, in, not_in, exists, not_exists. Decimal strings compare exactly, without floating point. |
hard | A hard rule cannot be lifted by an override. Use it for ceilings. |
reason_code | What the agent and the approver see, e.g. finance_approval_required, ceiling_exceeded. |
approval | Required for require_approval: approver_role, count (1–5 distinct approvers), separation_of_duties, decision_ttl_seconds, resume_ttl_seconds, optional connector_id. |
Amounts are decimal strings. Send "value": "500", not 500, so the comparison is exact.
How layers combine
Every layer is evaluated: emergency denies first, then enterprise policies (Control), project policies, then the grant's own rules.
- Any matching
denyblocks the action. - Otherwise every matching
require_approvalmust be satisfied. Requirements for the same role merge to the strictest: the higher count, the shorter deadlines, separation of duties if any rule asks for it. - Otherwise the action is allowed within the standing grant.
A project may therefore tighten an enterprise limit but cannot relax one.
Test before you publish
The policy simulator returns the expected result and the full rule trace for a hypothetical request, and creates nothing:
POST /v1/projects/{project_id}/simulate
content-type: application/json
{
"service_id": "svc_…",
"operation": "billing.refund.create",
"tenant_id": "ten_…",
"grant_id": "grt_…",
"resource": { "type": "invoice", "id": "417" },
"params": { "amount": "25000", "currency": "USD", "beneficiary": "acct_acme_ops" },
"candidate": { "policy_id": "pol_…", "rules": [ … ] }
}Without grant_id the grant's rules are not applied, and the response says so. candidate evaluates a draft in place of the active version of the same policy. The same simulator is available to developers as axiom simulate (pass --grant grt_… to apply the grant's rules, such as an amount threshold) and to MCP clients as axiom_simulate_policy.
POST /v1/projects/{pid}/policies/preview (or /v1/orgs/{oid}/policies/preview) replays a candidate against up to 200 recent actions in scope and lists every action whose result would change.
Project and enterprise policies
Console: Policies at /app/projects/:pid/policies; Control Org policies at /control/orgs/:oid/policies. The routes are the same with projects or orgs:
| Step | Call | Permission |
|---|---|---|
| Draft | POST /v1/{projects|orgs}/{id}/policies { name, rules, policy_id? } | policy.write |
| Publish | POST …/policies/{policy_id}/versions/{v}/publish { stage?: boolean, rollout_percent? } | policy.publish |
| Roll back | POST …/policies/{policy_id}/rollback { to_version, reason } | policy.publish |
A policy belongs to the project or organisation it was created in. Drafting a new version of, publishing or rolling back a policy through any other scope answers 404, exactly as if it did not exist. Publishing signs the version with Axiom's policy key and supersedes the previously active version, which stays available for rollback. The publishing user is recorded as the policy's author and is excluded from approving anything that policy requires.
Staged rollout is preview-only
stage: true marks a version staged and stores rollout_percent, but only active versions are evaluated. A staged version affects nothing until you publish it without stage; use preview and the simulator to judge its impact.
Overrides
An override lifts one non-hard rule for a limited scope. It needs an owner (you), a reason and an expiry of at most 30 days:
POST /v1/projects/{project_id}/overrides
content-type: application/json
{ "rule_id": "finance-approval-over-500", "reason": "Quarter-end backlog, approved by CFO", "expires_at": "2026-11-30T00:00:00Z", "tenant_id": "ten_…", "operation": "billing.refund.create" }Hard rules (ceilings) cannot be overridden. Overrides appear in the rule trace (overridden).
An override carries the authority of the scope it was created in: project for /v1/projects/{id}/overrides, enterprise for /v1/orgs/{id}/overrides. It lifts only matching rules at or below that layer. A project override can lift a project rule or a grant rule, never an enterprise rule, so overrides keep the tighten-only guarantee. An organisation override can lift enterprise, project and grant rules. Emergency denies are never lifted by an override; they end when the incident is released.
Quotas
New actions count against the project's plan. At the limit new metered work is refused with 402 quota_exceeded, while status, resume, approvals and evidence for work already in flight keep working. See Plans and limits.