Policy Reference
A Keel policy is a named document containing one or more rules. When a project has active policy rows, Keel evaluates them as part of the permit decision before allowing or denying a request.
For how policies fit into the broader permit evaluation, see Decision Model.
PolicyDocument shape
{
"name": "free-tier-guardrails",
"rules": [
{
"if": {"field": "context.account_tier", "op": "eq", "value": "free"},
"action": "deny_if_cost_exceeds",
"params": {
"window": "daily",
"cap_micros": 100000
}
}
]
}name— a label for the policy documentrules— an ordered array of rule objects, evaluated in array order
Condition nodes
Every rule has an "if" node that determines when the rule fires. Four shapes are supported:
| Shape | Meaning |
|---|---|
{"all": [...]} | AND — all child conditions must be true |
{"any": [...]} | OR — at least one child condition must be true |
{"not": {...}} | Negation of a single condition |
{"field": "...", "op": "...", "value": ...} | Leaf comparison |
Nodes can be nested arbitrarily deep.
{"all": []}is always true — use it when a rule should apply unconditionally{"any": []}is always false
For the full operator and field vocabulary, see Policy Conditions.
Evaluation order
- rules in a document are evaluated in array order
- a matching deny blocks the request; allow cannot override it
allowrules are non-terminal — seeallow(non-terminal)- review requirements accumulate while evaluation continues; review needs a separately matching positive grant, and a later deny or throttle still blocks
constrain_*rules are non-terminal and continue accumulating constraints after a match
Actions
Keel supports a typed set of action literals. Custom policy authoring is gated by plan tier — the runtime engine evaluates every action on every project, but what callers may author in custom policies depends on the project’s authoring level.
| Plan | Authoring level | Actions allowed in custom policies |
|---|---|---|
| Starter | Basic | deny, deny_if_cost_exceeds, deny_if_rate_exceeds, deny_if_spike_detected, deny_if_model_not_in, constrain_max_output_tokens, and require_human_review; up to 10 rules and no explicit allow. On Starter, an allow comes only from the project’s default policy and from applied templates |
Production and Enterprise (entitlement: policy_full_authoring) | Full | All currently released actions, including allow, require_human_review, throttle_if_rate_exceeds, constrain_permit_lifetime, constrain_permit_target, constrain_work_authority, and deny_if_projected_monthly_ratio_exceeds |
Approval and attestation gates carry an additional plan boundary independent of the authoring level. See Approver Groups for the typed approval_requirement schema and tier matrix.
allow (non-terminal)
Non-terminal. A matching allow rule does not end evaluation. Later rules in the same document, and rules in any later policy, are still evaluated for deny, require_human_review, throttle_if_rate_exceeds, and constrain_* actions.
The first matching allow rule’s policy attribution — policy_id, policy_name, policy_version, and rule_index — is preserved through a pending-allow accumulator on the engine result. When the engine falls through to allow after no later rule has denied, reviewed, or throttled the request, the permit’s policy attribution still points at the rule that contributed approval. Audit trails remain rule-level and audit-visible.
A deny, review or throttle rule that tests a fact does not match when that fact
is absent, null, the wrong type or a boolean. An allow must therefore carry
its own bounds for every consequential fact a sibling rule restricts: require
amounts and counts inside [minimum, ceiling] rather than only an upper
limit, allowlist the values a sibling deny denylists, and bind each spend field
to its own tool. Otherwise a request that simply omits the fact receives the
broad grant.
To require approval for an allow rule, attach an approval_requirement
(Enterprise) or the require_attestation shape with attestor: "project_owner"
(every plan). The policy protocol holds that result for review; public response
models serialize the decision as review rather than allow. See
Approver Groups for the typed schema.
Allow + later deny example
{
"name": "internal-allow-with-pii-deny",
"rules": [
{
"if": {"field": "context.account_tier", "op": "eq", "value": "internal"},
"action": "allow"
},
{
"if": {"field": "context.contains_pii", "op": "eq", "value": true},
"action": "deny"
}
]
}For an internal-tier request that also contains PII, both rules match. The first rule does not short-circuit. Evaluation continues, the deny rule fires, and the permit decision is deny with reason_code = policy.rule_denied. The deny is decisive — allow rules cannot suppress a later objection.
For an internal-tier request without PII, only the first rule matches. The engine records the allow rule’s metadata in the pending-allow accumulator, no later rule denies, throttles, or reviews, and the permit’s final decision is allow — attributed to the matched allow rule.
deny
Terminal. A matching deny rule ends evaluation with:
outcome = denyreason_code = policy.rule_denied
require_human_review
A matching review rule constrains positive authority and continues evaluation. It does not grant authority by itself. With a separately matching allow and no deny or throttle, it produces:
- protocol outcome
challenge, serialized publicly asdecision: "review" reason_code = policy.review_required
A review rule without an approver binds to the authenticated project owner, or the organization owner role if the project has no owner. A project with neither keeps an unconfigured requirement that cannot release execution. Multiple matching review requirements must be compatible; a broad fallback review must exclude requests already covered by a specific review branch.
Permits issued before review bound an approver may carry an advisory allow
with an unresolved review. They never authorize execution: dispatch, Work
children, realtime session turns and delegated children all refuse them.
Replaying such a permit’s idempotency key (issue or dry run) returns
legacy_review_replay_blocked (409) and leaves the signed historical record
unchanged. Request new authority with a new idempotency key, which issues a
bound review challenge.
The rule may carry an optional typed approval_requirement that names who can satisfy the challenge:
{
"approval_requirement": {
"type": "org_role",
"role": "admin",
"timeout_seconds": 1800
}
}Supported type values: org_role, user, approver_group, team, and service_principal.
Plan tier availability:
- Starter and Production — project-owner review only: a bare
require_human_reviewrule, orrequire_attestationwithattestor: "project_owner". Anyapproval_requirementis rejected withapproval_governance_tier_exceeded. - Enterprise — every
approval_requirementtype (org_role,user,approver_group,team,service_principal), multi-approval (min_approvals > 1), separation-of-duties, delegated approval, and external workflow metadata
For the full schema, the audit record per approval, and the authorization model, see Approver Groups.
deny_if_model_not_in
Terminal. Denies when the requested model is absent from the provided allow-list.
{
"action": "deny_if_model_not_in",
"params": {
"allowed": ["gpt-4o-mini", "claude-sonnet-5-5"]
}
}Reason code: policy.model_not_allowed
deny_if_cost_exceeds
Terminal. Denies when current or projected spend exceeds the configured cap.
{
"action": "deny_if_cost_exceeds",
"params": {
"window": "weekly",
"cap_micros": 5000000
}
}Supported windows: request, daily, weekly, monthly, quarterly.
How each window is checked:
requestdenies when the request’s estimated cost exceeds the cap.daily,weeklyandquarterlydeny when the window’s spend has reached the cap, or when that spend plus this request’s estimated cost would exceed it.monthlydenies only when the calendar month’s spend has reached the cap. To block earlier, on projected spend, adddeny_if_projected_monthly_ratio_exceedsor set the project’smonthly_cost_usd_micros_cap, which also denies a request whose projected monthly spend would exceed it.
Reason codes by window:
| Window | Reason code |
|---|---|
request | budget.request_cap_exceeded |
daily | budget.daily_cap_exceeded |
weekly | budget.weekly_cap_exceeded |
monthly | budget.monthly_cap_exceeded |
quarterly | budget.quarterly_cap_exceeded |
budget.pricing_unavailable fires when pricing is not configured for the requested model and cost cannot be safely estimated.
deny_if_rate_exceeds
Terminal. Hard deny when recent permit volume reaches the configured limit within the rolling window.
{
"action": "deny_if_rate_exceeds",
"params": {
"window_seconds": 60,
"max_requests": 100
}
}Reason code: budget.rate_limit_exceeded
throttle_if_rate_exceeds
Terminal. Requires Production or higher (entitlement: policy_full_authoring). Returns throttle (HTTP 429 + Retry-After) rather than a hard deny when recent permit volume reaches the configured limit.
{
"action": "throttle_if_rate_exceeds",
"params": {
"window_seconds": 60,
"max_requests": 50
}
}Reason code: budget.rate_limit_throttled
The decision_details.outcome_detail field carries retry_after_seconds,
window_seconds, limit, and observed for programmatic use. See
Decision Model › Throttling for the
full throttle outcome contract and SDK retry behavior.
deny_if_spike_detected
Terminal. Denies when projected day-to-date spend crosses baseline Ă— multiplier, where the baseline is computed from the prior baseline_days days.
{
"action": "deny_if_spike_detected",
"params": {
"multiplier": 2.0,
"baseline_days": 7
}
}Reason code: budget.daily_spike_detected
deny_if_projected_monthly_ratio_exceeds
Terminal. Requires Production or higher (entitlement: policy_full_authoring). Denies when projected monthly spend reaches a configured percentage of a monthly cap.
{
"action": "deny_if_projected_monthly_ratio_exceeds",
"params": {
"ratio_pct": 85,
"monthly_cap_micros": 10000000,
"projection": "estimated"
}
}projection may be current (actual spend to date) or estimated (spend to date plus the current request estimate).
Reason code: budget.monthly_threshold_exceeded
constrain_max_output_tokens
Non-terminal. Emits a max_output_tokens constraint and continues evaluating later rules.
{
"action": "constrain_max_output_tokens",
"params": {
"cap_tokens": 2048
}
}Constraint merge is most-restrictive-wins: if multiple matching rules emit max_output_tokens, the lowest cap survives. The final constraint is carried in the permit response. On Keel-managed execution surfaces, this constraint is enforced before provider dispatch. On permit-first flows, your application is responsible for honoring it.
Two-rule merge example
{
"name": "tiered-output-caps",
"rules": [
{
"if": {"all": []},
"action": "constrain_max_output_tokens",
"params": {"cap_tokens": 2048}
},
{
"if": {"field": "context.account_tier", "op": "eq", "value": "free"},
"action": "constrain_max_output_tokens",
"params": {"cap_tokens": 512}
}
]
}For a free-tier request, both rules match and emit max_output_tokens. The merged constraint is 512 — the lower cap. For a non-free-tier request, only the first rule matches, and the constraint is 2048. Because constrain_* actions are non-terminal, evaluation continues past each match and accumulates constraints from every rule that fires.
constrain_permit_lifetime
Non-terminal. Sets the longest lifetime a matching Permit may receive. The
caller can request a shorter lifetime with expires_at, but cannot extend the
Policy maximum.
{
"action": "constrain_permit_lifetime",
"params": {
"max_ttl_seconds": 900,
"not_after": "2026-08-01T18:00:00Z"
}
}max_ttl_seconds is required and measured from Permit issuance. not_after is
an optional absolute UTC boundary.
When multiple lifetime constraints match, the earliest boundary wins. Parent
Permit, authority, approval, platform and caller-requested boundaries can
shorten it further. The final Permit carries issued_at, expires_at and
constraint metadata naming the limiting source.
constrain_permit_target
Non-terminal. Limits which requested endpoints may receive a Permit. “Endpoint” means the action’s resource target; it is not limited to payment vendors.
{
"action": "constrain_permit_target",
"params": {
"allowed_resource_types": ["payment_endpoint"],
"allowed_resource_ids": ["endpoint_123"],
"allowed_providers": ["stripe"],
"allowed_operations": ["payment.execute"]
}
}At least one allow-list is required. Resource IDs use exact matching. The other
values are normalized before comparison. Multiple matching target constraints
intersect, so a later rule cannot widen an earlier one. A request outside the
effective allow-list is denied with policy.rule_denied.
The resulting target is request-bound. It does not claim that an independent remote endpoint verified the Permit; permit-first callers remain responsible for enforcing the constraint downstream.
constrain_work_authority
Non-terminal. Defines the maximum payment authority a future bounded Work request may receive. The dashboard exposes this as the three-step AI work Policy builder; saving the rule does not issue a Permit.
{
"if": {
"all": [
{
"field": "context._keel.verified_agent_principal_id",
"op": "exists",
"value": true
},
{"field": "action.name", "op": "eq", "value": "work.authorize"}
]
},
"action": "constrain_work_authority",
"params": {
"semantic_action": "payment.execute",
"max_uses": 4,
"value_max_minor": 100000,
"currency": "USD",
"automatic_review_threshold_minor": 10000,
"max_validity_seconds": 86400,
"parent_review_mode": "review_first_request_per_agent"
}
}semantic_action is any registered action name, such as payment.execute;
value_max_minor and currency are optional for actions that move no money.
Permit titles are not accepted as Policy inputs. Optional recipient_digests and
purpose_digests are SHA-256 allow-lists.
In the authority-grant vocabulary, the canonical action class is
payment_execute. The public action verb remains payment.execute.
mpp_payment is accepted only as a legacy input alias and should not be emitted
by new integrations.
parent_review_mode accepts:
review_every_request;review_first_request_per_agent; orallow_matching_automatically.
The automatic per-action review threshold cannot exceed the Policy value ceiling. Multiple Work constraints merge most-restrictive-wins for the same semantic authority; conflicting currency or unsafe scope does not widen the result. See AI Permit-to-X for the customer journey, issuance behavior, and Policy timing.
Validation
Keel validates policy documents at write time. The following are rejected:
- unknown top-level keys
- malformed condition nodes
- unknown operators
- unknown actions
- invalid
paramsshapes - unsafe
matches_regexpatterns
Regex authoring is intentionally constrained:
- maximum pattern length: 500 characters
- backreferences are rejected
- lookaround assertions are rejected
- patterns that can trigger catastrophic backtracking are rejected
- runtime matching is capped at 5 ms and fails closed
Examples
Unconditional model allow-list
{
"name": "approved-models-only",
"rules": [
{
"if": {"all": []},
"action": "deny_if_model_not_in",
"params": {
"allowed": ["gpt-4o-mini", "claude-sonnet-5-5"]
}
}
]
}Business-hours review gate
{
"name": "after-hours-review",
"rules": [
{
"if": {
"any": [
{"field": "context._keel.request_hour_utc", "op": "lt", "value": 9},
{"field": "context._keel.request_hour_utc", "op": "gte", "value": 17}
]
},
"action": "require_human_review",
"approval_requirement": {
"type": "org_role",
"role": "admin",
"timeout_seconds": 1800
}
}
]
}Free-tier throttle plus output cap
{
"name": "free-tier-throttle",
"rules": [
{
"if": {"field": "context.account_tier", "op": "eq", "value": "free"},
"action": "constrain_max_output_tokens",
"params": {"cap_tokens": 512}
},
{
"if": {"field": "context.account_tier", "op": "eq", "value": "free"},
"action": "throttle_if_rate_exceeds",
"params": {
"window_seconds": 60,
"max_requests": 20
}
}
]
}Monthly budget threshold with cost window cap
{
"name": "monthly-controls",
"rules": [
{
"if": {"all": []},
"action": "deny_if_projected_monthly_ratio_exceeds",
"params": {
"ratio_pct": 90,
"monthly_cap_micros": 50000000,
"projection": "estimated"
}
},
{
"if": {"all": []},
"action": "deny_if_cost_exceeds",
"params": {
"window": "daily",
"cap_micros": 2000000
}
}
]
}Accurate scope
- Policy rows are real and authoritative, but they are not the entire permit decision path. See Decision Model for the full picture.
- Project policy rows take precedence over organization rows. They do not stack.
- The evaluation context is intentionally compact. Do not assume field namespaces beyond those documented in Policy Conditions.