Skip to content

The rule language

Rules are the policy itself, written as a plain string. You’ll typically store these strings (per role, per user, per tenant) and load them in your resolver.

Throughout, “they” is the current user — the one your resolver was asked about. A rule set describes what this user can do with the resource it’s scoped to, not what everyone can do.

A rule is an optional if <expression> followed by one or more they can / they cannot clauses:

if is_self
they can view, update
they cannot delete
  • if <expression> — optional. When present, the clauses apply only where the expression holds. When omitted, the rule is unconditional (always applies).
  • they can <abilities> — grants the listed abilities.
  • they cannot <abilities> — denies the listed abilities.

Abilities are comma-separated. A rule may freely mix can and cannot clauses.

Warrant combines grants and denials with deny-overrides. For a given ability the compiled predicate is:

( any `can` rule for it matches ) AND ( no `cannot` rule for it matches )
  • A cannot is an absolute veto. An unconditional they cannot delete means this user can never delete any row — no can rule can bring it back.
  • An ability with no can rule is denied. Silence is not permission.
  • Rule order does not matter — the combination is commutative.
they can view # this user can view every row
if is_locked
they cannot update, delete # ...but never update/delete a locked row,
# even if another rule grants update

See the deny-overrides guide for the full semantics.

The if expression is a boolean combination of conditions:

if is_self or is_manager
if is_self and not is_locked
if is_manager and (in_team('sales') or in_team('eng'))
  • and, or — binary operators.
  • not — negation. ! is an accepted synonym (!is_lockednot is_locked); not is the canonical spelling.
  • Parentheses group sub-expressions.

Each bare name (is_self, is_manager) is a condition declared on the schema.

From tightest to loosest binding: not / ! > and > or. Parentheses override. So:

if is_self or not is_manager and is_owner

parses as is_self OR ((NOT is_manager) AND is_owner). When in doubt, parenthesize.

* stands for every ability the schema declares, on both sides:

if is_admin
they can * # every ability, when they're an admin
if is_suspended
they cannot * # loses every ability — a lockout that wins

they cannot * combined with deny-overrides is the idiomatic kill switch.

A condition can take arguments in three ways resolved before compilation — inline literals, named bindings, and positional bindings. A fourth source, check-time context, is resolved later, when the check runs.

Written directly in the rule. Supported types: string (single-quoted), int, float, bool, null.

if in_team('sales', 'eng') they can view
if seen_recently(30, true) they can view

Strings use single quotes; escape a quote or backslash with \' and \\. Lists and other complex values cannot be written inline — pass them via a binding.

Placeholders filled from a bindings array. The name is what matters: a binding may be reused any number of times, appear anywhere in the string (even across rules), and array order is irrelevant.

WarrantRuleSet::fromSyntax('documents', <<<'RULES'
if is_specific_user(:uid) they can view
if delegated_to(:uid) they can approve
RULES,
['uid' => $currentUserId], // one value, used twice
);

Filled left-to-right across the entire string from a flat array.

WarrantRuleSet::fromSyntax('documents',
'if in_team(?, ?) they can view',
['sales', 'eng'], // ? ? -> 'sales', 'eng'
);

Rules for bindings — enforced at parse time

Section titled “Rules for bindings — enforced at parse time”
  • A binding value may be any PHP value — string, int, array, an object, anything. (Only inline literals are restricted to scalars.) Your condition receives it verbatim on $c->arguments.
  • You may not mix named and positional bindings in one parse.
  • Every placeholder must have a value, and every provided value must be used. A missing binding, an unused binding, or a positional count mismatch is an error.

Some values are known only when the check runs — the current tenant, an academic year, an as-of date. Reach these with @context <key>, which stays symbolic in the rule and is filled from a context: array at check time:

if in_workspace(@context workspace_id) they can view, edit

The key must be declared on the schema with #[ContextKey]. Unlike :name / ? bindings, a @context reference is not subject to the parse-time “every binding used / no mixing” rules — it carries no value at parse time, may sit alongside literals and bindings, and never consumes a positional ?:

if scoped_to('projects', @context project_id, :region) they can view

Full behaviour — required vs. optional keys, the fail-open caveat on cannot — is covered in Check-time context.

Whitespace, multiple rules, reserved words

Section titled “Whitespace, multiple rules, reserved words”
  • Whitespace is insignificant. Newlines are cosmetic; an entire rule set can be one line. These are identical:

    if is_self they can view if is_manager they can approve
    if is_self
    they can view
    if is_manager
    they can approve
  • if starts a new rule. Every if begins a new rule; they can/cannot clauses attach to the most recent if above them. Clauses before any if form a single leading unconditional rule.

  • Reserved wordsif, they, can, cannot, and, or, not — cannot be used as an exact condition or ability name. A name may contain or start with one, though: canonical, cannot_publish, is_and_something are all fine.

  • Identifiers (condition, ability, and binding names) match [A-Za-z_][A-Za-z0-9_-]* — start with a letter or underscore; may contain letters, digits, underscores, and dashes. No dots.

ruleset = clause* ( "if" expr clause+ )* ;
clause = "they" ( "can" | "cannot" ) ability ( "," ability )* ;
ability = IDENTIFIER | "*" ;
expr = or ;
or = and ( "or" and )* ;
and = not ( "and" not )* ;
not = ( "not" | "!" ) not | primary ;
primary = "(" expr ")" | condition ;
condition = IDENTIFIER ( "(" ( arg ( "," arg )* )? ")" )? ;
arg = STRING | INT | FLOAT | BOOL | NULL | NAMED_BINDING | POSITIONAL | CONTEXT_REF ;
CONTEXT_REF = "@context" IDENTIFIER ;

Malformed syntax throws Warrant\RuleSyntaxTree\WarrantSyntaxException eagerly, with the line, column, and a caret pointing at the offending token — debuggable even when the whole rule set is one line:

Reserved word 'can' cannot be used as a name; expected an ability name. (line 1, column 21)
if is_self they can can
^

Name validation (does this ability/condition actually exist on the schema?) happens later, at compile time, when a rule set is compiled against a schema — also a hard error. See Errors & exceptions for the catalogue.