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:
. 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:
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 < 20permits exactly 20 executions, in parallel as well as in sequence. - A denied call consumes no tool budget. It counts toward
run.denialsinstead, 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.approvalsand 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.
Where facts aren’t collected
In a few places a decision happens outside any run scope. There, every counter reads zero andrun.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_threadand LangChain’s own executor all carry the run correctly; only a hand-rolledthreading.Threadloses it.
What a denied model is told
On arun.* 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 "andbillingare 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 firstagent_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, withHEXGATE_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_unavailabledoesn’t apply here: even underdeny, the cap is per-process. (Without a key, a platform-served policy doesn’t load at all, so this only affects local policies.)
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: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 < 100denies when the value is missing, butnot (agent_usage.invocations_1h >= 100)passes. A boundary module’sdenyonagent_usage.invocations_1h >= 100runs as that samenot.hexgate policy validateflags both asnegated-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:besideroles: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. Adenyin 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_unavailableis required. An emptyusage: {}is a load error, and so isusage: nullin a role: omit the key to inherit a parent’s value.
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(default5): the snapshot age after which a run start refreshes it in the background;HEXGATE_USAGE_SYNC_AFTER_SECONDS(default60): the age after which a run start waits for a fresh snapshot;HEXGATE_USAGE_MAX_STALENESS_SECONDS(default300): the age after which the snapshot is unavailable.
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 onadmission: 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 itsrun.* twin counts, summed over the window.
The differences:
invocationscounts 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: underagent_usage.invocations_1h < 100, the 100th run is admitted and can still call its tools.denialscounts denials inside a run. Admission and ban refusals are decided before the run starts, so they are not counted, the same as forrun.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, unlikerun.*_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 for1hand24h, under an hour for7dand30d.
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:
allowreads this process’s own counts alone. This is what you get when the block is left out.denyleaves 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:
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):
agent_usage_namespace, or
user_usage_namespace for a user cap:
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).
Always pair it with an agent ceiling
A rotated or anonymous ID still counts againstagent_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 auser_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 (
stalepast 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_idover 256 characters, or not valid UTF-8, readsunavailable. - 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.
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
orfailed.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
denynames its scope, so the snippet checksusage_statesfirst: 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 foragent_usage.*. - Anonymous callers. An empty
user_id, or noHexgateContext, has no usage to read, sousage.on_unavailabledecides (When platform counts are unavailable): underalloweveryuser_usage.*path reads zero, and underdenythey are missing and the constraints fail closed. Anonymous callers never share a count. To limit them, useagent_usage.*or actx.*or role rule. - Memory. The process keeps one count per user for the
HEXGATE_USAGE_USER_CACHEmost 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), oruser_usage_namespacewithassert_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.
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.
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:
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 throughinherits: — 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-levelconstraints: 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 aconsts: block and reference them as
consts.<name> — a number, string, or list:
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’scontent_hashfrom 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_hashis 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.
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 withand, or, not, and parentheses:
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 thisbilling role policy and async with HexgateContext(user_id="alice", user_roles=["billing"]):
refund_order(amount=200, currency="USD")→ ✅ allowedrefund_order(amount=600, currency="USD")→ ❌ denied — constraintargs.amount <= 500refund_order(amount=200, currency="EUR")→ ❌ denied — constraintargs.currency == "USD"wire_transfer(amount=50000)→ ✋ requires approval (mode =approval_required)
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).