Skip to content

Deny-overrides

Warrant combines every can and cannot — across all rules, including implicit rules — with deny-overrides. For a given ability the compiled predicate is:

predicate(ability) =
( OR of each `can` rule's if-expression )
AND ( AND of NOT(each `cannot` rule's if-expression) )

Because it’s a symmetric AND/OR combination, rule order never matters. You can merge rules from a resolver, implicit rules, and multiple clauses in any order.

Situation Compiles to Meaning
Unconditional cannot AND NOT(true)1 = 0 This user can never have the ability, on any row
No can rule for the ability 1 = 0 Denied by default — silence is not permission
Unconditional can 1 = 1 term This user has the ability on every row
Conditional cannot AND NOT(condition) Subtracts matching rows from the grant

No can rule can bring back an ability a cannot denies. This is what makes the “kill switch” idiom work:

they can * # grant everything...
if is_suspended
they cannot * # ...but a suspended user loses all of it

Since the combination is order-independent, an implicit cannot beats any resolver-supplied can:

protected function implicitRules(): array
{
return [
WarrantRule::fromSyntax('if is_suspended they cannot *'),
];
}

Exceptions read naturally as a guard on the deny:

if is_self or manages_team they can update
if is_locked and not is_admin they cannot update
if is_admin they can *

The middle line — “never once it’s locked, unless they’re an admin” — is a cannot update guarded by and not is_admin. No early returns, no ordering tricks.

If no rule mentions an ability with a can, that ability is denied, full stop. This trips people who expect an unlisted ability to be “allowed by default.” It isn’t. Every ability a user should have needs a matching can.

There’s one way deny-overrides can surprise you: an optional @context key that’s absent at check time makes its condition unevaluable → treated as false. On a can that’s safe (no key, no grant). But on a cannot, a false condition means the veto doesn’t apply — the deny silently lifts (fail-open). That’s exactly why any context key gating a cannot should be declared required. See Check-time context.