> ## Documentation Index
> Fetch the complete documentation index at: https://docs.hexgate.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Constraints reference

> Every operator, function, and quantifier the condition grammar accepts.

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).

<Note>
  New here? Start with **[Writing conditions](/policy/writing-conditions)** — a
  guided, example-first walkthrough. This page is the terse reference to look
  things up once you know the shape.
</Note>

| Operator          | Example                           | Notes                                   |
| ----------------- | --------------------------------- | --------------------------------------- |
| `==` `!=`         | `args.currency == "USD"`          | Strings use JSON double quotes          |
| `<` `<=` `>` `>=` | `args.amount <= 500`              | Type-mismatched comparisons fail-closed |
| `in`              | `args.template in ["a", "b"]`     | RHS must be a JSON list literal         |
| `not in`          | `args.priority not in ["urgent"]` | Two-word operator, treated as one       |

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(...)`:

| Operand     | Example                        | Notes                                         |
| ----------- | ------------------------------ | --------------------------------------------- |
| Field path  | `args.amount`                  | Missing path → fails closed (deny)            |
| Cross-field | `args.max >= args.min`         | A bare path on the right is a field reference |
| `count()`   | `count(args.recipients) <= 10` | Length of a list / string / object            |

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:

| Function     | Example                              | Notes                                                                                                                                                                        |
| ------------ | ------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `startswith` | `startswith(args.file_path, "src/")` |                                                                                                                                                                              |
| `endswith`   | `endswith(args.file_path, ".md")`    |                                                                                                                                                                              |
| `contains`   | `contains(args.subject, "urgent")`   | Substring (string only)                                                                                                                                                      |
| `matches`    | `matches(args.id, "^inv_[0-9]+$")`   | RE2 regex, **unanchored** — use `^…$` for a full match; backreferences / lookaround are rejected; `\d` `\w` `\s` `\b` are **ASCII** (so `\d` is `[0-9]`, not Unicode digits) |

**Quantifiers** constrain the elements of a list-valued argument. `.` is the
current element (`.field` for a sub-field), usable in any condition:

| Quantifier | Example                                     | Notes                                                     |
| ---------- | ------------------------------------------- | --------------------------------------------------------- |
| `every`    | `every(args.files, startswith(., "/tmp/"))` | All elements must match; empty list is vacuously **true** |
| `any`      | `any(args.roles, . == "admin")`             | At least one element matches; empty list is **false**     |

Element sub-fields and nesting work too:

```yaml theme={null}
constraints:
  - every(args.items, .price <= 100)
  - every(args.groups, any(.members, . == "admin"))
```

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`.

## 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`:

```yaml theme={null}
tools:
  refund_order:
    mode: allow
    constraints:
      - 'ctx.department == "finance"'
      - "ctx.clearance_level >= 3"
      - 'ctx.region in ["EU", "UK"]'
```

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.

## Named constants

Define reusable values once in a `consts:` block and reference them as
`consts.<name>` — a number, string, or list:

```yaml theme={null}
consts:
  max_refund: 500
  managed_repos: ["hexgate", "hexkit"]

tools:
  refund:    { mode: allow, constraints: ["args.amount <= consts.max_refund"] }
  create_pr: { mode: allow, constraints: ["args.repo in consts.managed_repos"] }
```

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.

## Boolean composition

Combine conditions with `and`, `or`, `not`, and parentheses:

```yaml theme={null}
constraints:
  - args.amount <= 100 or role == "admin"
  - not (args.env == "prod" and args.force == false)
```

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"])`:

```yaml theme={null}
tools:
  refund_order:
    mode: allow
    constraints:
      - args.amount <= 500
      - args.currency == "USD"
  wire_transfer:
    mode: approval_required
    constraints:
      - args.amount <= 100000
```

* `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).
