Skip to main content
Each tool can carry a constraints: list of string expressions evaluated against the call’s arguments. Every constraint must pass for the call to authorize (implicit AND).
New here? Start with Writing conditions — a guided, example-first walkthrough. This page is the terse reference to look things up once you know the shape.
Types are compared strictly, matching Rego: a boolean is not a number, so true == 1 is false and true in [1] is false (unlike Python’s True == 1). Cross-type ordered comparisons ("x" > 10) never pass — they fail closed. Each side of a comparison is an operand — a literal, a field path, or count(...): A bare unquoted word on the right is read as a field reference, so a forgotten-quotes typo (args.x == USD) compares against the field USD (usually absent → deny) rather than the string "USD". Quote strings. Whole-line string functions take a field and a string literal: Quantifiers constrain the elements of a list-valued argument. . is the current element (.field for a sub-field), usable in any condition: Element sub-fields and nesting work too:
A non-list collection (or a missing one) fails closed. . is only valid inside a quantifier body. Besides args.*, constraints can reference two call-scope facts: role (the caller’s role) and tool (the tool being invoked) — e.g. role == "admin" or tool == "refund_order". These mirror Rego’s input.role / input.tool. Four further namespaces follow below: ctx.* (who is calling), run.* (what this invocation has done so far), agent_usage.* (what this agent has done across runs) and user_usage.* (what one end user has done across agents).

Caller attributes (ctx.*)

Constraints can also filter on the caller’s attributes — an open bag set on the request scope (HexgateContext(attributes={...})) — through the ctx.<key> namespace, alongside args.* / role / tool:
A missing ctx.<key> fails closed (deny), like any absent reference. ctx.* may gate allow/deny decisions — but it carries the same trust contract as role: populate HexgateContext.attributes from trusted server-side data (your auth/session/IdP), never from raw client input. An attribute is exactly as trustworthy as the code that set it.

Run facts (run.*)

Everything above describes this call. run.* describes the invocation so far — how many tools the agent has already run, how long it has been going, how many tokens it has spent. It is what a runaway agent is stopped with. Facts are accumulated in-process by the SDK, so they are always available and never need a network call. A run starts at the adapter boundary (ainvoke, run, run_sync, astream_events, …) and ends when it returns.

The 13 paths

run.tools_used is list-valued, so read it with count(...), any(...) or every(...). A typo’d path, a deeper path (run.id.value), or a list path used where a scalar belongs are all rejected when the policy loads rather than denying silently at runtime.

What the counters mean

These distinctions change what a cap does, so they are worth reading once:
  • A tool counts when it is dispatched, after the decision has allowed it. run.tool_calls < 20 permits exactly 20 executions, in parallel as well as in sequence.
  • A denied call consumes no tool budget. It counts toward run.denials instead, which you can bound separately — otherwise an agent misbehaving against one tool would exhaust a legitimate caller’s budget.
  • An approval gate counts when it fires, not when it is granted. An approval that is refused adds to run.approvals and nothing else.
  • A guard halt before the tool is a denial; the tool never ran. A guard halt after the tool counts both: the call, because the side effect already happened and only the result is withheld, and a denial, because a post-guard halt is a refusal like any other.
  • A tool that raises counts as both a call and an error.
run.* caps are circuit breakers, not quotas. They stop a run that has gone wrong; they do not meter it precisely. Five places the count can lag reality:
  • Tokens trail by one turn. Tokens are only known once the model has answered, so the turn that blows the budget still gets its tool calls; the cap fires on the next decision.
  • Token facts are not available on the Pydantic AI adapter. It has no per-call usage hook, so it reports usage once, after the whole run finishes. run.input_tokens, run.output_tokens, run.total_tokens and run.llm_calls therefore read zero at every decision in a Pydantic AI run, and a token budget never fires there. Use run.tool_calls or run.elapsed_seconds to bound those runs. The LangChain, OpenAI and Google adapters all report per LLM call and are unaffected.
  • Sub-agents don’t roll up. A child invocation opens its own run, so its counts don’t reach the parent — a per-run cap is bypassable by spawning one.
  • Raw threads. A tool call is counted before it is dispatched, and on an event loop that read-then-increment cannot interleave — so parallel tool calls (asyncio.gather, LangChain’s executor) respect a cap exactly. Guarded calls dispatched to a hand-rolled threading.Thread can still overshoot, since two OS threads can be decided concurrently.
  • Parallel calls waiting on an approval. The guarantee above holds because nothing suspends between the decision and the count. An approval_required call breaks that: an async approval handler that actually waits — on a human, or on a network round-trip — hands the event loop back while the call is still uncounted, so other calls decide against the pre-approval count. A cap of 5 can admit every call in a concurrent fan-out of 50 approval-gated tools. Only the async path and only the policy’s own gate; a bool handler, a sync handler, and a before-guard’s approval all keep the count exact.

Where facts aren’t collected

In a few places a decision happens outside any run scope. There, every counter reads zero and run.id is "" — so a counter cap passes. This is deliberate: an unwired boundary should not brick the agent. The cases:
  • Network egress. The egress proxy is one per process and sits above every run, and a tool’s outbound connection carries no invocation id to correlate back, so egress decisions record nothing.
  • Methods that bypass the wrapper, such as LangChain’s agent.batch().
  • Tools that dispatch guarded work to a raw thread or thread pool. asyncio.gather, asyncio.to_thread and LangChain’s own executor all carry the run correctly; only a hand-rolled threading.Thread loses it.

What a denied model is told

On a run.* denial the model sees the constraint text — run.tool_calls < 20 — so it learns the threshold and that it was crossed. It is never told the current counter value, and the run’s id and counters are not in the payload the model receives. Budget pressure is deliberately not surfaced to the agent. One more, if you consume streams: two astream() generators consumed round-robin in the same task will cross-contaminate, because Python has no per-generator context. Consume one at a time, or put each in its own task. (This already affects caller identity via HexgateContext, so it isn’t new to run.*.)

Agent usage (agent_usage.*)

run.* caps one invocation. agent_usage.* caps this agent across runs, over a rolling window: “100 invocations per hour”, “2M tokens per day”. Each path is <metric>_<window>, such as agent_usage.invocations_1h or agent_usage.total_tokens_24h. The counts are shared by every process that runs the agent in the same project, and they are soft: a cap can be passed by a margin (see Shared across processes and the warning after it).

The paths

Seven metrics, each over five windows (5m, 1h, 24h, 7d, 30d), for 35 paths: Like run.*, a typo’d path (agent_usage.tool_call_1h), an unknown window (agent_usage.tool_calls_2h) or a path with no window is rejected when the policy loads.

Shared across processes

A value is the platform’s count for this agent, as last fetched, plus what this process has done since shortly before that fetch. The platform’s count covers every process and replica that runs the agent under the same project (the project of the API key).
  • Fetched at run start; a decision never waits on the network. The fetch happens at the run boundary, alongside the policy and ban checks. Inside a run, a decision reads the cached count plus the local one. When that count is stale, partial or unavailable, the decision may schedule a background refresh (one at a time per agent, at most every HEXGATE_USAGE_REFRESH_SECONDS), so a long run heals its own snapshot, but the decision itself never blocks on it.
  • Same name, same counter. Two deployments that use the same agent name in the same project share one count. Different projects never do. Names are trimmed, so " billing " and billing are one agent.
  • Run-start cost. A busy agent pays nothing extra: its run uses the cached count and refreshes it in the background. A freshly started process, or an agent whose runs are more than a minute apart, waits for one fetch, which times out after 2 seconds. A process’s first fetch first checks the API key against the platform’s signing key, under its own 2-second timeout, so it can take up to about 4 seconds. An agent whose policy references no agent_usage.* path never fetches. One exception to “alongside”: when a policy update gives an agent its first agent_usage.* cap, the run that picks it up fetches usage after the policy fetch, because the new paths weren’t known when the fetches started. A path added to a policy that already has caps is fetched in the background instead.
  • Local only, in three cases. With HEXGATE_LOCAL_MODE, with HEXGATE_LOCAL_POLICY, or without an API key, the platform isn’t asked. The values are then what this process has counted since it started: a restart resets them, and each replica counts its own. on_unavailable doesn’t apply here: even under deny, the cap is per-process. (Without a key, a platform-served policy doesn’t load at all, so this only affects local policies.)
agent_usage.* caps are soft quotas. They keep cost and fair use in check; they do not guarantee “never more than N”. No policy path counts exactly across runs: run.* bounds one invocation and starts again at zero in the next, so run.tool_calls < 3 with 10 runs an hour admits 30. For “at most 3 wire transfers per hour, ever”, put the tool behind approval_required so a person decides each transfer, or enforce the limit in the service that executes it. How far a value can be off:
  • Other processes are seen late. Their activity reaches this process after the snapshot’s age plus the platform’s ingest lag. A cap can be passed by roughly (the rate of every other process) × (that delay). With 4 replicas each admitting 1 run per second, a 5-second refresh and 10 seconds of ingest lag, agent_usage.invocations_1h < 100 can let through about 3 × 1 × 15 ≈ 45 extra runs at the moment it is crossed, and about 3 × 1 × 21 ≈ 63 at the top of the normal range below. Size the overshoot from the upper bound. The delay is:
    • normally 5 to about 21 seconds: a busy process’s snapshot is up to 5 seconds old (a little more while a refresh is in flight), the platform may reuse an answer for up to 1 second, and the ingest lag adds 5 to 15 seconds;
    • up to about a minute (plus the ingest lag) for a process whose runs are 5 to 60 seconds apart, since its run reads the cached snapshot while the background refresh is in flight; and inside a long run, since a snapshot under 60 seconds old is not refreshed mid-run. A process whose runs are more than a minute apart fetches before the run instead;
    • up to 5 minutes while the platform is unreachable or refuses the request, on the last good snapshot;
    • after that, the fail mode applies.
  • Overlapping admissions. A run is counted when it starts, not when it is admitted. Runs admitted at the same moment (sync runs on separate threads, or a run waiting on an approval) can all read the same count and each pass, even in one process.
  • It errs toward counting more. Besides windows rounding up, two ways a value reads high, each on purpose:
    • this process’s events from the 30 seconds before each fetch (up to 90 seconds once the snapshot is over 270 seconds old) can be counted twice, once locally and once on the platform, so its own events still in the pipeline are never missed;
    • a call that hits approval_required counts as a tool call when the gate fires, even if the approval is refused.
  • It can read low when the SDK’s span queue drops spans under saturation. Dropped spans never reach the platform, and the local count only adds back this process’s last 30 seconds before each fetch, so drops older than that are lost, this process’s included.
  • Not latching. A denial clears as usage ages out of the window (Rolling, not latching).

When the platform can’t answer

By default, runs continue. A value the platform can’t supply reads this process’s own count alone: zero, plus what this process has done. A platform outage weakens a cap to per-process; it never stops an agent. To fail closed instead:
Under deny, a value the platform can’t supply is absent, so a comparison on it fails closed, like any missing field.
  • Write caps as plain comparisons. Under deny, agent_usage.invocations_1h < 100 denies when the value is missing, but not (agent_usage.invocations_1h >= 100) passes. A boundary module’s deny on agent_usage.invocations_1h >= 100 runs as that same not. hexgate policy validate flags both as negated-usage-cap.
  • Per path. When a policy update adds a usage path the cached snapshot doesn’t have yet, only that path follows the fail mode until the next refresh. The rest of the policy keeps its platform counts.
  • Where the block goes. usage: is agent-wide:
    • in a single-file policy, at the top level;
    • in a role-keyed document, inside each role, or in a mixin every role inherits. A usage: beside roles: is a load error;
    • in a policies/ directory, in each role file, or in a mixin they inherit;
    • in a composed policy, at the entry file’s top level or in an agent body. An imported fragment can’t declare it, and neither can a boundary or capability module.
  • One value per agent. The snapshot is fetched per agent, so every role must resolve to the same mode, and a role that says nothing reads allow. A deny in some roles only fails to load, with an error that names each role’s value.
  • It is signed. The mode travels in the signed policy bundle, so a caller can’t strip deny. An SDK that predates the key ignores it in a signed bundle and fails open; it rejects the policy only when it loads the YAML itself (a local policy, or a platform with no compiled bundle). Upgrade every SDK and the platform before you add the block.
  • on_unavailable is required. An empty usage: {} is a load error, and so is usage: null in a role: omit the key to inherit a parent’s value.
Every decision that reads a usage root carries its state on its audit span: sec_ai.usage_state for agent_usage.*, sec_ai.user_usage_state for user_usage.*. The state says how the values were built: A decision that reads user_usage.* for a named caller also carries sec_ai.usage_identity = asserted: the user_id is the one your code put in the context. It is the only tier today. The platform doesn’t store or display usage_state, user_usage_state or usage_identity yet.
Three environment variables tune the agent refresh:
  • HEXGATE_USAGE_REFRESH_SECONDS (default 5): the snapshot age after which a run start refreshes it in the background;
  • HEXGATE_USAGE_SYNC_AFTER_SECONDS (default 60): the age after which a run start waits for a fresh snapshot;
  • HEXGATE_USAGE_MAX_STALENESS_SECONDS (default 300): the age after which the snapshot is unavailable.
Three more tune the user refresh, with the same meaning: HEXGATE_USAGE_USER_REFRESH_SECONDS (default 30), HEXGATE_USAGE_USER_SYNC_AFTER_SECONDS (default 60) and HEXGATE_USAGE_USER_MAX_STALENESS_SECONDS (default 300).Each trio must satisfy 0 < refresh ≤ sync_after ≤ max_staleness. A bad value logs a warning, and that trio falls back to its defaults. A shorter refresh means less overshoot and more load on the platform.

Where to put a limit

Put invocation and budget caps on admission: they then refuse the run before it starts. The run being admitted is not counted yet, so use <: agent_usage.invocations_1h < 100 admits about 100 runs per hour (see the soft-quota warning above). A top-level constraints: entry also reaches every tool call inside a run, not only admission.

What the usage counters mean

Each metric counts exactly what its run.* twin counts, summed over the window. The differences:
  • invocations counts admitted runs, other than the current one. A run refused at admission is not counted. Inside a run, invocations_* leaves that run out, so a top-level cap reads the same at admission and at every tool call in the run: under agent_usage.invocations_1h < 100, the 100th run is admitted and can still call its tools.
  • denials counts denials inside a run. Admission and ban refusals are decided before the run starts, so they are not counted, the same as for run.denials.
  • Tokens trail. They are known only once the model answers, so a token cap stops the next admission or call, not the one that crossed it. On the Pydantic AI adapter, usage is reported when each run finishes. So agent_usage.*_tokens_* does cap later runs there, unlike run.*_tokens.
  • Rolling, not latching. A denial clears as usage ages out of the window. There are no calendar resets. This is the opposite of run.*, which only ever grows.
  • Windows round up, never down. The oldest bucket counts whole, so a window can read more than its nominal length. The platform’s share counts in minutes, so it can start up to a minute early, and it doesn’t age out until the next refresh. This process’s own share covers everything since 30 seconds before the fetch, whatever the window. So a window can read high by up to the snapshot’s age plus 30 seconds plus a minute, and one more minute once the snapshot is over 270 seconds old: about 1.5 minutes normally, about 7.5 minutes on a snapshot near 5 minutes old. With no snapshot, this process’s share alone follows its local buckets: under a second more for 5m, under a minute for 1h and 24h, under an hour for 7d and 30d.

When platform counts are unavailable (usage.on_unavailable)

Counts shared across processes come from the platform. The usage block says what a decision reads when the platform’s count is missing:
  • allow reads this process’s own counts alone. This is what you get when the block is left out.
  • deny leaves the value missing, so the constraints that read it fail closed. The rest of the policy keeps working.
on_unavailable is required once you write the block. The setting is per agent, because the platform counts per agent, so every role has to resolve to the same value. A role that leaves the block out reads allow, so setting deny on one role only fails validation:
The value is signed into the bundle. It applies to both roots whenever the platform can’t supply a value: the state is partial (for the missing paths) or unavailable. In local mode there is no platform count to miss, so it doesn’t apply, with one exception: a caller with no user_id has no usage to read, so under deny every user_usage.* constraint fails closed for it, at zero usage and in every mode (Anonymous callers). A hexgate chat session or a scheduled run without a HexgateContext user is then refused.

Dry-running a quota

hexgate policy test takes --agent-usage, which sets the combined value a decision would read; no platform is involved. Unset paths read zero, as in a fresh process, so the fail mode isn’t simulated:
--user-usage does the same for user_usage.* (User usage):
In a pytest suite, build the namespace with agent_usage_namespace, or user_usage_namespace for a user cap:
On an agent_usage.* denial the model is told what it is told for run.* (What a denied model is told), and never the usage_state.

User usage (user_usage.*)

agent_usage.* caps the agent as a whole, so one heavy user can use up the cap for everyone. user_usage.* caps one end user across every agent: the user is HexgateContext.user_id, and the paths are the agent ones, under a second root (The paths).
user_id is asserted by your code. Derive it from your authenticated session (HexgateContext(user_id=session.user.id)), never from a value the end user sends: a user who can choose their ID can rotate it and dodge the cap.

Always pair it with an agent ceiling

A rotated or anonymous ID still counts against agent_usage.*, so the two caps go together: the user cap bounds each user’s share, the agent ceiling bounds the total whatever IDs arrive. The example above shows the pair.

Shared across processes (users)

A user’s value is the platform’s count for that user across every process and agent of the project, as last fetched, plus what this process did since shortly before the fetch. It is a soft quota, as for agents (Shared across processes).
  • Fetched at run start, alongside the policy, ban and agent fetches, only when the policy references user_usage.* and the context has a user_id.
  • A cold user waits once. A user whose snapshot is missing or over a minute old waits for one fetch (2-second timeout). A user who keeps running is refreshed in the background every 30 seconds.
  • One failure pauses every user. After a failed user fetch, no user waits or refreshes for 30 seconds. A user with a snapshot keeps reading it (stale past a minute) until it is five minutes old; a user with no snapshot, or an older one, reads the fail mode. A 401, 403, 404, 405 or 422 pauses every user fetch for five minutes (the platform has no user read, or refuses the key or the request).
  • Unreadable IDs are never sent. A user_id over 256 characters, or not valid UTF-8, reads unavailable.
  • Local in the same three cases as agents: local mode, local policy, or no API key.
  • Logs never print the ID. A user appears in SDK log lines as a 12-character fingerprint.
Per-user overshoot, at the moment a user crosses the cap:

Telling the user from the agent

AgentNotAdmittedError.quota_scopes (and Decision.quota_scopes, for a tool denial) names the usage scopes (UsageScopeKind.AGENT, UsageScopeKind.USER) whose roots the failing constraints reference, so your app can answer a user over quota (a 429 to them) differently from an agent over quota (an ops alert):
  • It names the scopes a failing constraint references, not which side of an or failed. user_usage.invocations_1h < 50 or ctx.plan == "pro" reports {UsageScopeKind.USER}.
  • Every failing constraint counts. Both engines report each one, so a run over both caps names both scopes.
  • An unavailable value counts too. A cap that fails because its value is missing under deny names its scope, so the snippet checks usage_states first: a platform outage pages ops rather than telling the user they hit a limit.
  • The model is told nothing about either.

How it counts

  • Across agents. Every run started with user_id="alice" in context counts for alice, whatever the agent. The cap is project-wide, so a user’s runs of a cheap agent count against a cap on an expensive one, and can lock the user out of it. A hexgate agent called as a tool opens its own run, so it counts a second invocation, as for agent_usage.*.
  • Anonymous callers. An empty user_id, or no HexgateContext, has no usage to read, so usage.on_unavailable decides (When platform counts are unavailable): under allow every user_usage.* path reads zero, and under deny they are missing and the constraints fail closed. Anonymous callers never share a count. To limit them, use agent_usage.* or a ctx.* or role rule.
  • Memory. The process keeps one count per user for the HEXGATE_USAGE_USER_CACHE most recent users (default 10 000), about 118 KB each. Every window’s buckets are kept per user, with a platform or without, so the cost stays the same. A user dropped from that cache starts again from its platform count, or from zero without one.
  • Dry-run with --user-usage (same shape as --agent-usage), or user_usage_namespace with assert_denies(..., user_usage=...) (Dry-running a quota).

Where a constraint can live

Three places, widening in reach: default_policy.constraints is the common trap. It is the catch-all policy, not a floor: once a tool is listed under tools:, the default no longer applies to it, so a run cap written there silently misses every tool you named.
Policy-level constraints can only narrow. A tool with mode: deny is denied before constraints are consulted, so nothing here can grant access.

args.* at policy level reaches admission and reach

“Every tool the role can reach” includes the synthetic keys admission: and agents: lower to — agent.run, plus agent.handoff:<target> and agent.tool:<target>. Those gates pass only their own arguments (args.agent for admission; args.agent / args.target / args.via for reach), so an args.* constraint written with tools in mind reads a missing field there, and a missing ref fails closed: the agent is refused before it starts, and every handoff and agent-as-tool call is denied.
This policy denies its own agent at ingress.
Not an admission quirk — the same constraint denies any tool that doesn’t take an amount. Policy level is the place for predicates every reachable key can answer: run.*, ctx.*, role. Keep args.* on the tool that defines the argument.
When you do want one argument fenced role-wide, exempt the whole reserved namespace — tool is a fact like any other, and the grammar has or:
Use the prefix, not tool == "agent.run": naming the admission key alone still leaves agent.handoff:<target> and agent.tool:<target> denied. A skills: block lowers the same way, and skill decisions carry skill, via, file_path and content_hash (plus the script’s invocation args on Google ADK; see below) — none of them amount, so the same args.amount fence denies every skill activation too. Exempt both skill prefixes alongside agent.:

Inheritance unions them

Every other field overrides through inherits: — a child’s tools entry replaces its parent’s. Policy-level constraints are the exception: they accumulate, so a child cannot drop a fence a mixin declared.
Policy-level constraints belong in a single-file policy — flat, or role-keyed under roles:, where a top-level block unions into every role. Composed module policies (policies/boundaries/, policies/capabilities/) reject them at load: the fold composes per-tool rules only, and silently dropping a role-wide fence would be fail-open.

Upgrading

Top-level constraints: is new. Single-file policies ignore keys they don’t recognise, so before this release the block parsed and was silently dropped — after it, the same YAML is enforced against every tool the role can reach. If you wrote one speculatively, it starts denying on upgrade, and the bundle’s rego_hash and wasm_hash change with it. Note that source_hash does not move — it is the sha256 of your policy’s raw YAML bytes, which the upgrade leaves untouched, so diffing it will tell you nothing changed. Grep your policies for a top-level constraints: before you roll forward — hexgate policy show-rego prints the compiled rules if you want to diff exactly what moved. (Composed module policies are unaffected: they rejected the key outright, then and now.) Role-keyed documents activate one release later. A document with a top-level roles: key validated only what sat inside each role, so a constraints: sibling was dropped there even after the release above — including a malformed expression, which hexgate policy validate could not see either, since there was nothing left to validate. It now unions into every role, so the same upgrade note applies: grep role-keyed policies too. Any other unrecognised sibling of roles: is now a load error rather than a silent drop, naming the key and telling you to move it inside a role.

Named constants

Define reusable values once in a consts: block and reference them as consts.<name> — a number, string, or list:
Change a value in one place and every tool/role that references it updates. Constants merge through inherits: like tools, so shared constants belong in a mixin. A consts.<name> that isn’t defined is rejected at build (and denies on the pydantic engine); across roles a name must map to a single value.

Pinning a skill’s content

A skill is matched by name, so editing an approved skill does not revoke its approval. Every skill decision carries these arguments, so pinning needs no new syntax — just a constraint and a constant:
  • The value carries a sha256: prefix. A bare hex digest never matches — and the digest Hexgate records is bare. Take the skill’s content_hash from the agent’s registered manifest and write it as "sha256:<content_hash>", or compute it from the file with the same helpers the adapters use:
  • It fails closed. If the hash cannot be computed, content_hash is null, the constraint does not match, and the pinned skill denies rather than passes.
  • It closes drift, not prompt injection. A pinned skill whose approved content was already hostile is still allowed. Review the text before you pin it.
The hash covers the SKILL.md body at every level, so a pin on a rule whose via includes resource or script also stops those when the instructions change — but it does not cover the resource and script files themselves. On Google ADK, a script decision also carries the script’s invocation as args.script_args, args.short_options and args.positional_args.

Boolean composition

Combine conditions with and, or, not, and parentheses:
Precedence is the usual or < and < not; use () to group. Multiple constraint lines are still AND-ed, so and is only needed to combine with an or. (Boolean ops inside a quantifier body — e.g. every(args.x, .a == 1 or .b == 2) — aren’t supported yet.)

End to end

With this billing role policy and async with HexgateContext(user_id="alice", user_roles=["billing"]):
  • refund_order(amount=200, currency="USD") → ✅ allowed
  • refund_order(amount=600, currency="USD") → ❌ denied — constraint args.amount <= 500
  • refund_order(amount=200, currency="EUR") → ❌ denied — constraint args.currency == "USD"
  • wire_transfer(amount=50000) → ✋ requires approval (mode = approval_required)
Switch to user_roles=["default"] and refund_order is missing from that policy — it falls through to default_policy.mode (deny). Constraints are Rego-compatible by design: the WASM engine compiles them to OPA Rego unchanged, and the pydantic engine evaluates the same strings in-process — both produce identical decisions (there’s a parity test suite that proves it).