Skip to content

Policy diagnostics

A diagnostic is one finding the policy compiler reports about a policy file: an error means the policy is not accepted, and a warning means it is accepted but you should look at that line. Every diagnostic has a stable code, like unknown_key; codes are API, so new ones are added and existing ones are never renamed. In JSON a diagnostic has the fields severity, code, message, hint (the next step, when there is one), file, line and column (1-based; line 0 means the whole file), end_line and end_column (exclusive), rule_id, path (the YAML path, like rules[0].when) and doc_url, which points to the section of this page for that code. Run deedglass policy lint policy.yaml to check a file in your terminal, add --json for the result as JSON, or --format github to annotate a pull request in GitHub Actions.

A diagnostic in JSON, from deedglass policy lint --json policy.yaml:

{
"severity": "error",
"code": "unknown_key",
"message": "Unknown key \"decison\" in rule \"small-refunds\".",
"hint": "Did you mean \"decision\"?",
"file": "policy.yaml",
"line": 10,
"column": 5,
"end_line": 10,
"end_column": 12,
"rule_id": "small-refunds",
"path": "rules[0].decison",
"doc_url": "https://docs.deedglass.com/policies/errors/unknown_key"
}

The same diagnostic in the terminal. ✕ marks an error and ○ a warning; code spans lose their backticks.

✕ policy.yaml:10:5 · unknown_key
Unknown key "decison" in rule "small-refunds".
9 │ when: args.amount <= minor("50.00 usd")
10 │ decison: allow
│ ^^^^^^^
hint: Did you mean "decision"?

And with --format github, one workflow command per diagnostic:

::error file=policy.yaml,line=10,col=5::unknown_key: Unknown key "decison" in rule "small-refunds". Did you mean "decision"?

Each section below gives the severity, what the code means, a small policy that produces it, what the compiler prints and the fix. To keep them short, the printed output shows the first line, the message and the hint, and leaves out the source excerpt with carets that the terminal prints between the message and the hint. The language itself is described in POLICY_LANGUAGE.md.

Severity: error

The file is larger than 256 KiB. Deedglass checks the size before it reads any YAML, so a very large file can’t slow down the compiler or the Console. A policy is rarely more than a few kilobytes; a file this size usually holds rules that differ only in a tool name.

✕ policy.yaml · file_too_large
This policy is 298 KiB; the limit is 256 KiB.
hint: Split it into smaller policies, or replace repeated rules with globs.

Fix: replace repeated rules with one rule and a glob or a list, like match: { tool: "stripe.*" }, and remove large comment blocks.

Severity: error

The file is not valid YAML, or it is not valid UTF-8. The most common cause is a when with : in it, which YAML reads as a new key.

deedglass: policy/v1
name: refunds
defaults:
decision: deny
reason: No rule allows this action.
rules:
- id: small-refunds
match: { tool: stripe.create_refund }
when: has(args.amount) ? args.amount <= 5000 : false
decision: allow
✕ policy.yaml:9:50 · yaml_syntax
The YAML could not be read: mapping values are not allowed.
hint: Quote the expression or write it as a block (when: >-), because YAML reads a colon followed by a space as a new key.

Fix: quote the expression.

when: 'has(args.amount) ? args.amount <= 5000 : false'

Severity: error

A line is indented with a tab. YAML does not allow tabs for indentation, and a tab can look like any number of spaces in an editor, so Deedglass reports every such line. In the snippet below, line 5 starts with a tab.

deedglass: policy/v1
name: refunds
defaults:
decision: deny
reason: No rule allows this action.
✕ policy.yaml:5:1 · yaml_tab_indent
Line 5 is indented with a tab.
hint: Use spaces; YAML does not allow tabs for indentation.

Fix: indent with spaces.

defaults:
decision: deny
reason: No rule allows this action.

Severity: error

The file uses a YAML anchor (&name) or alias (*name). An alias makes one rule depend on text somewhere else in the file, so a reviewer can’t read a rule on its own, and aliases can expand a small file into a very large one.

deedglass: policy/v1
name: refunds
defaults:
decision: deny
reason: No rule allows this action.
rules:
- id: small-refunds
match: &refund { tool: stripe.create_refund }
when: args.amount <= minor("50.00 usd")
decision: allow
- id: large-refunds
match: *refund
decision: require_approval
reason: Refunds over $50.00 wait for a person.
✕ policy.yaml:8:12 · yaml_alias
Anchors and aliases (& and *) are not allowed in policies.
hint: Repeat the value, so each rule reads on its own.

Fix: write the value out in each place.

- id: small-refunds
match: { tool: stripe.create_refund }
when: args.amount <= minor("50.00 usd")
decision: allow
- id: large-refunds
match: { tool: stripe.create_refund }
decision: require_approval
reason: Refunds over $50.00 wait for a person.

Severity: error

The file uses a YAML merge key (<<). Like an alias, a merge key hides part of a rule somewhere else, so Deedglass refuses it.

deedglass: policy/v1
name: refunds
defaults:
decision: deny
reason: No rule allows this action.
rules:
- id: large-refunds
<<: { decision: require_approval }
match: { tool: stripe.create_refund }
reason: Refunds wait for a person.
✕ policy.yaml:8:5 · yaml_merge_key
Merge keys (<<) are not allowed in policies.
hint: Repeat the keys, so each rule reads on its own.

Fix: write the keys in the rule itself.

- id: large-refunds
match: { tool: stripe.create_refund }
decision: require_approval
reason: Refunds wait for a person.

Severity: error

A value starts with !, which YAML reads as a tag. Policies use plain YAML values only; the YAML 1.2 core tags (!!str, !!int and so on) are the only ones allowed. The usual cause is a CEL condition that starts with !.

deedglass: policy/v1
name: postgres-safe
defaults:
decision: deny
reason: No rule allows this action.
rules:
- id: single-statements
match: { tool: "postgres.*" }
when: !sql.multi
decision: allow
✕ policy.yaml:9:11 · yaml_tag
!sql.multi is read by YAML as a tag, not as CEL.
hint: Quote the expression: when: '!sql.multi'

Fix: quote the expression.

when: '!sql.multi'

Severity: error

The file holds more than one YAML document, separated by ---. One file is one policy, so what you review is exactly what is stored.

deedglass: policy/v1
name: refunds
defaults:
decision: deny
reason: No rule allows this action.
---
deedglass: policy/v1
name: postgres-safe
defaults:
decision: deny
reason: No rule allows this action.
✕ policy.yaml:6:1 · yaml_multi_document
This file holds more than one YAML document.
hint: Keep one policy per file, and remove the --- line.

Fix: move each policy into its own file, and remove the --- line.

Severity: error

A key appears twice in the same mapping. Most YAML readers keep only one of the two values without telling you, so the file would mean something other than what it shows.

deedglass: policy/v1
name: refunds
defaults:
decision: deny
reason: No rule allows this action.
rules:
- id: small-refunds
match: { tool: stripe.create_refund }
when: args.amount <= 5000
when: args.amount <= 50000
decision: allow
✕ policy.yaml:10:5 · yaml_duplicate_key
Key "when" appears twice in this mapping (first on line 9).
hint: Keep one of them; YAML would silently use only one.

Fix: keep one of the two lines. To combine both conditions, join them with && in a single when.

Severity: error

The YAML nests more than 32 levels deep, or has more than 50,000 nodes (every key, value and list entry is a node). A real policy needs a few levels and a few thousand nodes; the limits keep the compiler fast on any input.

deedglass: policy/v1
name: refunds
defaults:
decision: deny
reason: No rule allows this action.
rules:
- id: deep
decision: allow
match:
labels: [[[[[[[[[[[[[[[[[[[[[[[[[[[[[[]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]
✕ policy.yaml:10:43 · yaml_too_complex
This policy nests more than 32 levels deep.
hint: Flatten the structure; a policy needs only a few levels.

A file with more than 50,000 nodes gets a different message, at the node where counting stopped:

✕ policy.yaml:731:79 · yaml_too_complex
This policy has more than 50,000 YAML nodes; the limit is 50,000.
hint: Split it into smaller policies, or replace repeated rules with globs.

Fix: flatten the structure, and replace repeated rules with globs, like match: { tool: "stripe.*" }.

Severity: error

The file has no deedglass: policy/v1 key, or it is empty. The marker names the format, so Deedglass and your editor know the file is a policy and which version of the language it uses. An empty file gets the message This file is empty.

name: refunds
defaults:
decision: deny
reason: No rule allows this action.
✕ policy.yaml:1:1 · missing_marker
This file has no deedglass: policy/v1 marker.
hint: Add deedglass: policy/v1 as the first line.

Fix:

deedglass: policy/v1
name: refunds
defaults:
decision: deny
reason: No rule allows this action.

Severity: error

The marker names a format this version of Deedglass does not read, like policy/v2, or something that is not a policy format at all.

deedglass: policy/v2
name: refunds
defaults:
decision: deny
reason: No rule allows this action.
✕ policy.yaml:1:12 · unsupported_format
deedglass: policy/v2 is not supported by this version of Deedglass, which reads policy/v1.
hint: Write deedglass: policy/v1, or upgrade Deedglass.

Fix: write deedglass: policy/v1, or upgrade Deedglass if the policy needs a newer format.

Severity: error

A key is not part of the language at that place. Deedglass refuses unknown keys instead of ignoring them, because a misspelled key would otherwise do nothing without telling you. The hint suggests the closest valid key, or says where a key belongs (tool goes inside match:). A top-level version: 1 gets this code too, with the hint to use deedglass: policy/v1.

deedglass: policy/v1
name: refunds
defaults:
decision: deny
reason: No rule allows this action.
rules:
- id: small-refunds
match: { tool: stripe.create_refund }
when: args.amount <= minor("50.00 usd")
decison: allow
✕ policy.yaml:10:5 · unknown_key
Unknown key "decison" in rule "small-refunds".
hint: Did you mean "decision"?

Fix:

decision: allow

Severity: error

A required key is missing: name or defaults at the top level, decision in defaults, id or decision in a rule, or id, window or a limit (max_calls or max_spend) in a budget.

deedglass: policy/v1
name: refunds
defaults:
decision: deny
reason: No rule allows this action.
rules:
- id: small-refunds
match: { tool: stripe.create_refund }
when: args.amount <= minor("50.00 usd")
✕ policy.yaml:7:5 · missing_key
Rule "small-refunds" has no decision.
hint: Add decision: allow, deny or require_approval.

Fix:

- id: small-refunds
match: { tool: stripe.create_refund }
when: args.amount <= minor("50.00 usd")
decision: allow

Severity: error

A value has the wrong shape: text where a mapping belongs, a list where a single value belongs, a quoted number where a whole number belongs, or an empty when. The message names what was expected and what was found. Some wrong_type messages have no hint, because the message already says what to write.

deedglass: policy/v1
name: refunds
defaults:
decision: deny
reason: No rule allows this action.
rules:
- id: small-refunds
match: stripe.create_refund
decision: allow
✕ policy.yaml:8:12 · wrong_type
match must be a mapping, like { tool: "stripe.*" }; found a string.

Fix:

match: { tool: stripe.create_refund }

Severity: error

Two keys can’t be used together. A budget limits either calls (max_calls) or spending (max_spend), not both, and measure only works with max_spend.

deedglass: policy/v1
name: refunds
defaults:
decision: deny
reason: No rule allows this action.
budgets:
- id: refunds-per-day
match: { tool: stripe.create_refund }
window: day
max_calls: 100
max_spend: "500.00 usd"
✕ policy.yaml:11:16 · conflicting_keys
Budget "refunds-per-day" sets both max_calls and max_spend.
hint: Keep one; use two budgets to limit both.

Fix: use two budgets.

budgets:
- id: refund-calls-per-day
match: { tool: stripe.create_refund }
window: day
max_calls: 100
- id: refund-spend-per-day
match: { tool: stripe.create_refund }
window: day
max_spend: "500.00 usd"

Severity: error

The policy name is not a slug. A name is 2 to 63 characters of lowercase ASCII letters, digits and dashes, starting with a letter, so it reads the same in a URL, a log line and the Console.

deedglass: policy/v1
name: Refunds Policy
defaults:
decision: deny
reason: No rule allows this action.
✕ policy.yaml:2:7 · invalid_name
Policy name "Refunds Policy" is not a slug.
hint: Use lowercase letters, digits and dashes, starting with a letter, like refunds-policy.

Fix:

name: refunds-policy

Severity: error

A rule or budget id is not a slug. Ids follow the same rules as names, because they appear in deny messages, in deeds and in the Ledger.

deedglass: policy/v1
name: refunds
defaults:
decision: deny
reason: No rule allows this action.
rules:
- id: small_refunds
match: { tool: stripe.create_refund }
decision: allow
✕ policy.yaml:7:9 · invalid_id
Rule id "small_refunds" is not a slug.
hint: Use lowercase letters, digits and dashes, starting with a letter, like small-refunds.

Fix:

- id: small-refunds

Severity: error

Two rules, two budgets, or a rule and a budget share an id. Budgets and rules share one set of ids, so a decision always names exactly one of them.

deedglass: policy/v1
name: refunds
defaults:
decision: deny
reason: No rule allows this action.
rules:
- id: refunds
match: { tool: stripe.create_refund }
when: args.amount <= minor("50.00 usd")
decision: allow
- id: refunds
match: { tool: stripe.create_refund }
decision: require_approval
reason: Refunds over $50.00 wait for a person.
✕ policy.yaml:11:9 · duplicate_id
Rule id "refunds" is used twice (first on line 7).
hint: Give each rule its own id.

Fix: give each rule its own id, like small-refunds and large-refunds.

Severity: error

The policy has more than 500 rules. Rules are read top to bottom, and a person reviewing the file has to be able to follow them. A policy with 501 rules gets:

✕ policy.yaml:7:3 · too_many_rules
This policy has 501 rules; the limit is 500.
hint: Combine rules with match globs and lists, or split the policy.

Fix: combine rules that differ only in a tool or an agent into one rule with a glob or a list, like match: { tool: ["github.create_issue", "github.create_comment"] }.

Severity: error

The policy has more than 50 budgets. Every budget is checked for every action, so the limit keeps evaluation fast. A policy with 51 budgets gets:

✕ policy.yaml:7:3 · too_many_budgets
This policy has 51 budgets; the limit is 50.
hint: Combine budgets with match globs, or split the policy.

Fix: combine budgets with the same window and limit into one budget whose match uses a glob or a list.

Severity: error

A list has more than 32 entries. The limit applies to each match list (tool, connector_type, side_effect, agent), to labels, to approvers and to notify. A rule whose match.agent lists 33 agent names gets:

✕ policy.yaml:9:14 · too_many_values
agent lists 33 values; the limit is 32.
hint: Use a glob, or split the rule.

Fix: use a glob, like agent: ["support-*"], or give the agents a label and match on it, like labels: { team: support }.

Severity: error

A description is longer than 1,000 characters. A policy whose description is 1,011 characters gets:

✕ policy.yaml:3:14 · value_too_long
The description is 1,011 characters; the limit is 1,000.
hint: Shorten it.

Fix: keep the description to a few sentences, and put longer explanations in comments (#), which fmt keeps and the hash ignores.

Severity: error

A decision is not allow, deny or require_approval. The hint maps common words (block, reject, approve, permit and others) to the decision you probably meant.

deedglass: policy/v1
name: postgres-safe
defaults:
decision: deny
reason: No rule allows this action.
rules:
- id: block-drop
match: { tool: "postgres.*" }
when: sql.kind == "drop"
decision: block
reason: DROP is never allowed for agents.
✕ policy.yaml:10:15 · invalid_decision
block is not a decision.
hint: Did you mean deny? Decisions are allow, deny and require_approval.

Fix:

decision: deny

Severity: error

A field that takes one of a fixed set of values has something else: verify (L0, L1, L2), a budget’s per (agent, org) or window (minute, hour, day), or a notify channel (email, slack, webhook).

deedglass: policy/v1
name: refunds
defaults:
decision: deny
reason: No rule allows this action.
budgets:
- id: refunds-per-week
match: { tool: stripe.create_refund }
window: week
max_calls: 100
✕ policy.yaml:9:13 · invalid_value
week is not a budget window.
hint: Use minute, hour or day.

Fix: use a supported window. For a weekly limit, set a daily one.

- id: refunds-per-day
match: { tool: stripe.create_refund }
window: day
max_calls: 15

Severity: error

A whole number is outside its range: max_calls is 1 to 1,000,000,000, and an approval’s required is 1 to 5.

deedglass: policy/v1
name: refunds
defaults:
decision: deny
reason: No rule allows this action.
budgets:
- id: agent-rate
window: minute
max_calls: 0
✕ policy.yaml:9:16 · out_of_range
max_calls: 0 is out of range.
hint: Use a number from 1 to 1,000,000,000.

Fix: to stop every call, write a rule with decision: deny instead of a budget of zero; otherwise set a limit of at least 1.

max_calls: 60

Severity: error

A rule (or defaults) denies or requires approval but has no reason. The reason is the one sentence the agent, and the person who approves, will see; without it a denial can’t be explained.

deedglass: policy/v1
name: postgres-safe
defaults:
decision: deny
reason: No rule allows this action.
rules:
- id: block-drop
match: { tool: "postgres.*" }
when: sql.kind == "drop"
decision: deny
✕ policy.yaml:10:15 · reason_missing
Rule "block-drop" denies but has no reason.
hint: Write the one sentence the agent will see, like "DROP is never allowed for agents."

Fix:

decision: deny
reason: DROP is never allowed for agents.

Severity: error

A string contains a control character, a bidirectional control character or an invisible formatting character (like U+200B, a zero-width space). These can make text display differently from what it says, so a reviewer could approve something other than what runs. Tabs are allowed in descriptions only.

deedglass: policy/v1
name: refunds
defaults:
decision: deny
reason: "No rule allows​ this action."
✕ policy.yaml:5:11 · control_character
The reason contains an invisible character (U+200B).
hint: Remove it. It can make text display differently from what it says.

Fix:

reason: No rule allows this action.

Severity: error

A pattern in match (or in a glob() call) is not a valid glob. Globs use * and ? only, with letters, digits and _ . : -; they are at most 200 characters and never empty. An agent: entry that starts with agt_ must be a whole agent id.

deedglass: policy/v1
name: refunds
defaults:
decision: deny
reason: No rule allows this action.
rules:
- id: small-refunds
match: { tool: "stripe/create_refund" }
when: args.amount <= minor("50.00 usd")
decision: allow
✕ policy.yaml:8:20 · invalid_glob
stripe/create_refund is not a valid pattern.
hint: Patterns use * and ? only, with letters, digits and _ . : -.

Fix: tool names are <connector>.<tool>.

match: { tool: stripe.create_refund }

Severity: error

A side_effect in match is not read, write, destructive or unknown. These are the values tools declare, so no globs are allowed here.

deedglass: policy/v1
name: postgres-safe
defaults:
decision: deny
reason: No rule allows this action.
rules:
- id: reads
match: { tool: "postgres.*", side_effect: reads }
decision: allow
✕ policy.yaml:8:47 · invalid_side_effect
reads is not a side effect.
hint: Did you mean read? Use read, write, destructive or unknown.

Fix:

match: { tool: "postgres.*", side_effect: read }

Severity: error

A label in match.labels is not valid. Keys use lowercase letters, digits and . _ / - (up to 63 characters, starting with a letter or digit), and values are at most 128 printable characters, the same rules as agent labels.

deedglass: policy/v1
name: refunds
defaults:
decision: deny
reason: No rule allows this action.
rules:
- id: prod-refunds
match: { tool: stripe.create_refund, labels: { Env: prod } }
decision: require_approval
reason: Refunds in production wait for a person.
✕ policy.yaml:8:52 · invalid_label
Env is not a label key.
hint: Label keys use lowercase letters, digits and . _ / -, up to 63 characters, like env or team.

Fix:

match: { tool: stripe.create_refund, labels: { env: prod } }

Severity: error

An approval’s expires_in is not a duration, or is outside 1m to 7d. A duration is a whole number and one unit: s, m, h or d.

deedglass: policy/v1
name: refunds
defaults:
decision: deny
reason: No rule allows this action.
rules:
- id: large-refunds
match: { tool: stripe.create_refund }
decision: require_approval
reason: Refunds wait for a person.
approval: { approvers: [role:admin], expires_in: 2 hours }
✕ policy.yaml:11:54 · invalid_duration
expires_in: 2 hours is not a duration.
hint: Use a number and one unit: 30m, 2h or 1d (from 1m to 7d).

Fix:

approval: { approvers: [role:admin], expires_in: 2h }

Severity: error

An amount of money is a YAML number. YAML numbers are floating point, and 0.1 + 0.2 is not 0.3 in floating point, so money is always a string with a currency.

deedglass: policy/v1
name: refunds
defaults:
decision: deny
reason: No rule allows this action.
budgets:
- id: refunds-per-day
match: { tool: stripe.create_refund }
per: org
window: day
max_spend: 500.00
✕ policy.yaml:11:16 · money_float
max_spend: 500.00 is a YAML number.
hint: Write money as a string, like "50.00 usd", so no precision is lost.

Fix:

max_spend: "500.00 usd"

Severity: error

A money string is not "<decimal> <currency>", or the amount is negative, zero (for max_spend) or larger than 9,007,199,254,740,991 in minor units. The same check applies to minor() inside a condition.

deedglass: policy/v1
name: refunds
defaults:
decision: deny
reason: No rule allows this action.
budgets:
- id: refunds-per-day
match: { tool: stripe.create_refund }
per: org
window: day
max_spend: "$500"
✕ policy.yaml:11:16 · invalid_money
$500 is not an amount of money.
hint: Write money as a string with a currency, like "50.00 usd".

Fix:

max_spend: "500.00 usd"

Severity: error

The currency is not an active ISO 4217 code. The code is three letters, in any case; fmt writes it in lowercase.

deedglass: policy/v1
name: refunds
defaults:
decision: deny
reason: No rule allows this action.
budgets:
- id: refunds-per-day
match: { tool: stripe.create_refund }
per: org
window: day
max_spend: "500.00 usx"
✕ policy.yaml:11:16 · unknown_currency
usx is not an active ISO 4217 currency.
hint: Did you mean "usd"? Use a three-letter code, like usd, eur or inr.

Fix:

max_spend: "500.00 usd"

Severity: error

The amount has more decimals than the currency’s minor unit: 2 for usd and inr, 0 for jpy, 3 for kwd. Deedglass never rounds money for you, so the amount you read is the amount that is enforced.

deedglass: policy/v1
name: refunds
defaults:
decision: deny
reason: No rule allows this action.
budgets:
- id: refunds-per-day
match: { tool: stripe.create_refund }
per: org
window: day
max_spend: "500.005 usd"
✕ policy.yaml:11:16 · money_precision
500.005 usd has more decimals than usd allows (2).
hint: Round it to 2 decimals, like "500.01 usd".

Fix:

max_spend: "500.01 usd"

Severity: error

An entry in approvers is not role:owner, role:admin or user:<email>. role:viewer is refused because viewers can’t approve.

deedglass: policy/v1
name: refunds
defaults:
decision: deny
reason: No rule allows this action.
rules:
- id: large-refunds
match: { tool: stripe.create_refund }
decision: require_approval
reason: Refunds wait for a person.
approval: { approvers: [priya@acme.com] }
✕ policy.yaml:11:29 · invalid_approvers
priya@acme.com is not an approver.
hint: Write it as user:priya@acme.com.

Fix:

approval: { approvers: ["user:priya@acme.com"] }

Severity: error

An approval needs more approvals than the people it names. When every approver is a user:, required can be at most the number of distinct people, or no action could ever be approved.

deedglass: policy/v1
name: refunds
defaults:
decision: deny
reason: No rule allows this action.
rules:
- id: large-refunds
match: { tool: stripe.create_refund }
decision: require_approval
reason: Refunds wait for two people.
approval: { approvers: ["user:priya@acme.com"], required: 2 }
✕ policy.yaml:11:63 · approvals_unsatisfiable
This rule needs 2 approvals but names only 1 person.
hint: Lower required, or add people or a role to approvers.

Fix:

approval: { approvers: ["user:priya@acme.com", "user:sam@acme.com"], required: 2 }

Severity: error

A when or measure is not valid CEL. The hint covers the two most common slips: = instead of ==, and and, or or not instead of &&, || or !. An expression nested more than 64 levels deep gets this code too.

deedglass: policy/v1
name: refunds
defaults:
decision: deny
reason: No rule allows this action.
rules:
- id: exact-refund
match: { tool: stripe.create_refund }
when: args.amount = 5000
decision: allow
✕ policy.yaml:9:23 · cel_syntax
Syntax error in when: unexpected token '='.
hint: Compare with ==

Fix:

when: args.amount == 5000

Severity: error

An expression uses a name that is not one of the policy variables: tool, args, request, sql, agent, user, session, org, time, usage and anomaly. There is no now(); use time.now.

deedglass: policy/v1
name: refunds
defaults:
decision: deny
reason: No rule allows this action.
rules:
- id: small-refunds
match: { tool: stripe.create_refund }
when: arg.amount <= 5000
decision: allow
✕ policy.yaml:9:11 · cel_undeclared
Unknown name "arg".
hint: Did you mean "args"? Available: tool, args, request, sql, agent, user, session, org, time, usage and anomaly.

Fix:

when: args.amount <= 5000

Severity: error

An expression reads a field that its variable does not have. Every variable except args is declared field by field, so a typo is an error when you lint, never a condition that is quietly false at run time.

deedglass: policy/v1
name: postgres-safe
defaults:
decision: deny
reason: No rule allows this action.
rules:
- id: block-drop
match: { tool: "postgres.*" }
when: sql.knid == "drop"
decision: deny
reason: DROP is never allowed for agents.
✕ policy.yaml:9:15 · cel_no_field
sql has no field "knid".
hint: Did you mean "kind"?

Fix:

when: sql.kind == "drop"

Severity: error

An expression does not type-check: an operator or function is applied to values of the wrong types. The most common case is comparing a value with a quoted number using <, <=, > or >=: a quoted number is a string, and strings compare letter by letter, so "10000" < "5000".

deedglass: policy/v1
name: refunds
defaults:
decision: deny
reason: No rule allows this action.
rules:
- id: small-refunds
match: { tool: stripe.create_refund }
when: args.amount <= "5000"
decision: allow
✕ policy.yaml:9:26 · cel_type
args.amount <= "5000" compares a number with a string.
hint: Compare numbers with numbers: args.amount <= 5000 (minor units, so 5000 is $50.00).

Fix:

when: args.amount <= 5000

Severity: error

A when returns something other than true or false, like a string. A condition whose type is only known at run time (dyn, like args.flag) is accepted, and a value that turns out not to be a bool counts as an evaluation error, which never widens access.

deedglass: policy/v1
name: postgres-safe
defaults:
decision: deny
reason: No rule allows this action.
rules:
- id: selects
match: { tool: "postgres.*" }
when: sql.kind
decision: allow
✕ policy.yaml:9:11 · cel_not_bool
when must be true or false; this expression returns a string.
hint: Compare it with something, like args.status == "open"

Fix:

when: sql.kind == "select"

Severity: error

A budget’s measure returns something other than a whole number. A spending budget counts minor units, so measure must return an int (or dyn, which is checked at run time).

deedglass: policy/v1
name: refunds
defaults:
decision: deny
reason: No rule allows this action.
budgets:
- id: refunds-per-day
match: { tool: stripe.create_refund }
per: org
window: day
max_spend: "500.00 usd"
measure: string(args.amount)
✕ policy.yaml:12:14 · cel_not_int
measure must be a whole number of minor units; this expression returns a string.
hint: Return an int, like args.amount, or guard it: has(args.amount) ? args.amount : 0

Fix:

measure: args.amount

Severity: error

The estimated worst-case cost of a when or measure is more than 100,000 units. Deedglass evaluates every condition inline, before the action runs, so the cost of each one is bounded when the policy compiles. CEL can’t know how long a list will be, so the estimate assumes up to 1,024 entries for each list in args; a loop inside a loop over arguments is about a million steps.

deedglass: policy/v1
name: orders
defaults:
decision: deny
reason: No rule allows this action.
rules:
- id: distinct-items
match: { tool: shop.create_order }
when: args.items.all(a, args.items.all(b, a != b))
decision: allow
✕ policy.yaml:9:11 · cel_cost
This condition could cost up to 113,252,355 units per call; the limit is 100,000.
hint: Iterate a smaller list (sql.tables has at most 64 entries), avoid a loop inside a loop over args, or split the rule.

Fix: iterate one list at a time, like when: args.items.all(i, i.sku != "gift-card"). The estimate uses the size hints, not the conditions around the loop, so adding size(args.items) <= 20 && in front of a nested loop does not lower it.

Severity: error

Comprehensions (all, exists, exists_one, map, filter) are nested more than 2 deep. Deeply nested loops are hard to read and multiply the cost of a condition.

deedglass: policy/v1
name: orders
defaults:
decision: deny
reason: No rule allows this action.
rules:
- id: no-gift-tags
match: { tool: shop.create_order }
when: args.orders.all(o, o.items.all(i, i.tags.all(t, t != "gift")))
decision: allow
✕ policy.yaml:9:52 · cel_nesting
Comprehensions are nested 3 deep; the limit is 2.
hint: Flatten the condition, or split it into two rules.

Fix: check a flatter value, like a flag the tool sets on each order: when: args.orders.all(o, !o.gift).

Severity: error

A when or measure is longer than 4,096 characters. A long expression is usually a long list of values written inline. A when of 5,281 characters gets:

✕ policy.yaml:9:11 · cel_too_long
This when is 5,281 characters; the limit is 4,096.
hint: Split the rule, or move a list into match.

Fix: move lists of tools or agents into match, where globs can shorten them, or split the condition across two rules.

Severity: error

The pattern in matches() is not a constant string, is longer than 256 characters, or is not valid RE2. Patterns come from the policy author, never from the agent’s arguments, and they are checked when the policy compiles.

deedglass: policy/v1
name: email
defaults:
decision: deny
reason: No rule allows this action.
rules:
- id: acme-recipients
match: { tool: gmail.send }
when: args.to.matches(args.pattern)
decision: allow
✕ policy.yaml:9:27 · cel_regex
The pattern in matches() must be a constant string.
hint: Patterns come from the policy, never from an agent's arguments: write the pattern in quotes.

Fix:

when: args.to.matches("^[a-z.]+@acme[.]com$")

Severity: error

glob() was given a pattern, or minor() an amount, that is not a constant string. Like matches() patterns, these come from the policy, so an agent can’t choose what a rule matches.

deedglass: policy/v1
name: refunds
defaults:
decision: deny
reason: No rule allows this action.
rules:
- id: allowed-tools
when: glob(tool.name, args.pattern)
decision: allow
✕ policy.yaml:8:27 · cel_constant_required
glob() needs a constant pattern.
hint: Patterns come from the policy, never from an agent's arguments: write it in quotes, like glob(tool.name, "stripe.*")

Fix:

when: glob(tool.name, "stripe.*")

Severity: warning

A reason is longer than 280 characters. The policy is accepted, but agents and approvers see at most 280 characters, so the end of the reason is cut off. A reason of 311 characters gets:

○ policy.yaml:10:13 · reason_too_long
This reason is 311 characters. Agents and approvers see at most 280.
hint: Shorten it to one sentence; the rest is cut off.

Fix: write one sentence that says what was refused and what to do next, like Refunds over $50.00 wait for a person. An admin can approve in Console › Approvals.

Severity: warning

A connector_type in match is not a known connector type. The policy is accepted, because new connector types can be added later, but a misspelled type matches nothing. Known types: postgres, rest, mcp, mcp_http, sdk, github, slack, stripe, gmail, notion and fake.

deedglass: policy/v1
name: postgres-safe
defaults:
decision: deny
reason: No rule allows this action.
rules:
- id: reads
match: { connector_type: postgress, side_effect: read }
decision: allow
○ policy.yaml:8:30 · unknown_connector_type
postgress is not a known connector type.
hint: Did you mean postgres?

Fix:

match: { connector_type: postgres, side_effect: read }

Severity: warning

A rule sets a field its decision does not use: approval on anything but require_approval, notify on allow, or verify on deny. The policy is accepted and the field has no effect, which is usually not what the author meant.

deedglass: policy/v1
name: refunds
defaults:
decision: deny
reason: No rule allows this action.
rules:
- id: small-refunds
match: { tool: stripe.create_refund }
when: args.amount <= minor("50.00 usd")
decision: allow
notify: [slack]
○ policy.yaml:11:5 · ignored_field
notify is ignored because the decision is allow.
hint: Remove it; notifications go out for denials and approval requests.

Fix: remove notify: [slack], or change the decision to require_approval if a person should see these refunds.

Severity: warning

An earlier rule with no when matches everything a later rule matches, so the first match always comes from the earlier rule and the later one never decides anything. The policy is accepted. Deedglass only warns when this is certain from match.

deedglass: policy/v1
name: refunds
defaults:
decision: deny
reason: No rule allows this action.
rules:
- id: stripe-reads
match: { tool: "stripe.*" }
decision: allow
- id: block-refunds
match: { tool: stripe.create_refund }
decision: deny
reason: Refunds are never made by agents.
○ policy.yaml:10:5 · rule_unreachable
Rule "block-refunds" can never match: rule "stripe-reads" (line 7) matches everything it does and has no when.
hint: Move rule "block-refunds" above rule "stripe-reads", or narrow rule "stripe-reads".

Fix: put the narrower rule first.

rules:
- id: block-refunds
match: { tool: stripe.create_refund }
decision: deny
reason: Refunds are never made by agents.
- id: stripe-reads
match: { tool: "stripe.*" }
decision: allow

Severity: warning

defaults.decision is allow, so every action no rule matches runs, including tools added after you wrote the policy. The policy is accepted, but enforcing it needs an explicit acknowledgement.

deedglass: policy/v1
name: refunds
defaults:
decision: allow
rules:
- id: block-refunds
match: { tool: stripe.create_refund }
decision: deny
reason: Refunds are never made by agents.
○ policy.yaml:4:13 · default_allow
defaults.decision: allow lets every unmatched action run.
hint: Deedglass recommends deny. Enforcing this policy will need an explicit acknowledgement.

Fix: deny by default, and allow what you need with rules.

defaults:
decision: deny
reason: No rule allows this action. Add a rule in Console › Policies to allow it.

Severity: warning

A line looks like an API key or token (for example a Deedglass, Stripe, GitHub, Slack or AWS key). Policies are stored, versioned and shown to admins, so a secret in a policy is a secret shared with everyone who can read it. The policy is accepted; the message shows only the key’s prefix.

deedglass: policy/v1
name: storage
description: Uses key AKIAIOSFODNN7EXAMPLE for uploads.
defaults:
decision: deny
reason: No rule allows this action.
○ policy.yaml:3:23 · secret_in_policy
Line 3 looks like a secret (AKIA…).
hint: Policies are stored and shown to admins. Keep secrets in the connector vault.

Fix: remove the key from the policy, keep it in the connector vault, and rotate it if the file was ever shared.

Severity: warning

A reason does not follow the Deedglass voice: it has an exclamation mark, starts with a lowercase letter, does not end with a period, or uses a word Deedglass copy never uses. This check runs only when you ask for it, with deedglass policy lint --voice.

deedglass: policy/v1
name: postgres-safe
defaults:
decision: deny
reason: No rule allows this action.
rules:
- id: block-drop
match: { tool: "postgres.*" }
when: sql.kind == "drop"
decision: deny
reason: DROP is never allowed for agents!
○ policy.yaml:11:13 · reason_style
This reason has an exclamation mark.
hint: Say it calmly, without an exclamation mark.

Fix:

reason: DROP is never allowed for agents.

Severity: error or warning

The file has more than 50 diagnostics. Deedglass reports the first 50, sorted by position, and then this one entry, which counts the rest. Its severity is error when any of the hidden diagnostics is an error, and warning when they are all warnings, so a file with only warnings is still accepted. A file with 60 invalid rule ids gets, after the first 50:

✕ policy.yaml · more_diagnostics
And 10 more (10 errors, 0 warnings).
hint: Fix the ones above and run lint again.

Fix: fix the diagnostics shown, starting from the top, and run deedglass policy lint again. Many come from one cause, like a wrong indentation or a repeated mistake.

Severity: error

The compiler itself failed on this file. This is a bug in Deedglass, not in your policy. A policy that can’t be compiled is never accepted, so a compiler failure can’t let an action through.

✕ policy.yaml · internal_compiler_error
Deedglass could not compile this policy (an internal error), so it was not accepted.
hint: Report it with the file; it is a bug in Deedglass.

Fix: report it to Deedglass with the policy file and the output of deedglass policy lint --json. Remove any secrets from the file first.

  • POLICY_LANGUAGE.md: the policy language, its limits and the CEL environment.
  • CLI.md: deedglass policy lint and deedglass policy fmt.