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
- the first terminal match wins
allowrules are non-terminal — seeallow(non-terminal)constrain_*rules are non-terminal and continue accumulating constraints after a match
Actions
Keel supports nine 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 | Templates only | None directly. Apply preset templates via POST /v1/projects/{project_id}/apply-policy-template/{template_id}. |
Growth (entitlement: policy_basic_authoring) | Basic | deny, deny_if_model_not_in, deny_if_cost_exceeds, deny_if_rate_exceeds, deny_if_spike_detected, constrain_max_output_tokens |
Business and Enterprise (entitlement: policy_full_authoring) | Full | All nine actions, including allow, require_human_review, throttle_if_rate_exceeds, 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.
To get a terminal outcome from an allow rule, attach an approval_requirement (or the legacy require_attestation shape). Those keep the rule terminal at challenge 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
Terminal. A matching review rule ends evaluation with:
outcome = challengereason_code = policy.review_required
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:
- Business — legacy
require_attestation, plus typedorg_roleoruserwith a single approver - Enterprise (entitlement:
organization_dashboard_enabled) —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-3-5-haiku-latest"]
}
}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.
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 Business 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 reason_detail.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 Business 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.
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-3-5-haiku-latest"]
}
}
]
}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.