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.
Anatomy of a rule
Section titled “Anatomy of a rule”A rule is an optional if <expression> followed by one or more they can /
they cannot clauses:
if is_selfthey can view, updatethey cannot deleteif <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.
can, cannot, and deny-overrides
Section titled “can, cannot, and deny-overrides”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
cannotis an absolute veto. An unconditionalthey cannot deletemeans this user can never delete any row — nocanrule can bring it back. - An ability with no
canrule is denied. Silence is not permission. - Rule order does not matter — the combination is commutative.
they can view # this user can view every rowif is_lockedthey cannot update, delete # ...but never update/delete a locked row, # even if another rule grants updateSee the deny-overrides guide for the full semantics.
Boolean logic
Section titled “Boolean logic”The if expression is a boolean combination of conditions:
if is_self or is_managerif is_self and not is_lockedif is_manager and (in_team('sales') or in_team('eng'))and,or— binary operators.not— negation.!is an accepted synonym (!is_locked≡not is_locked);notis the canonical spelling.- Parentheses group sub-expressions.
Each bare name (is_self, is_manager) is a condition declared on the
schema.
Operator precedence
Section titled “Operator precedence”From tightest to loosest binding: not / ! > and > or. Parentheses
override. So:
if is_self or not is_manager and is_ownerparses as is_self OR ((NOT is_manager) AND is_owner). When in doubt,
parenthesize.
Wildcards
Section titled “Wildcards”* stands for every ability the schema declares, on both sides:
if is_adminthey can * # every ability, when they're an admin
if is_suspendedthey cannot * # loses every ability — a lockout that winsthey cannot * combined with deny-overrides is the idiomatic kill switch.
Passing arguments to conditions
Section titled “Passing arguments to conditions”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.
Inline literals
Section titled “Inline literals”Written directly in the rule. Supported types: string (single-quoted), int,
float, bool, null.
if in_team('sales', 'eng') they can viewif seen_recently(30, true) they can viewStrings use single quotes; escape a quote or backslash with \' and \\. Lists
and other complex values cannot be written inline — pass them via a binding.
Named bindings (:name)
Section titled “Named bindings (:name)”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);Positional bindings (?)
Section titled “Positional bindings (?)”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.
Check-time context (@context)
Section titled “Check-time context (@context)”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, editThe 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 viewFull 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 approveif is_selfthey can viewif is_managerthey can approve -
ifstarts a new rule. Everyifbegins a new rule;they can/cannotclauses attach to the most recentifabove them. Clauses before anyifform a single leading unconditional rule. -
Reserved words —
if,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_somethingare 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.
Formal grammar
Section titled “Formal grammar”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 ;Syntax errors
Section titled “Syntax errors”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.
