How it compiles to SQL
You don’t need this to use Warrant, but it explains why the semantics are what they are — and why the three questions can never disagree.
One predicate per ability
Section titled “One predicate per ability”For each requested ability, the compiler assembles one predicate from all the
rules that mention it (or *):
predicate(ability) = ( OR of each `can` rule's if-expression ) AND ( AND of NOT(each `cannot` rule's if-expression) )with these hard edges:
- An unconditional
cannot→AND NOT(true)→1 = 0: never, on any row. - No
canrule for the ability →1 = 0: denied by default. - An unconditional
can→ an always-true1 = 1term: every row.
See Deny-overrides for how this plays out in practice.
Every condition is an EXISTS leaf
Section titled “Every condition is an EXISTS leaf”Each condition leaf is wrapped as an EXISTS subquery. That makes it a strict
boolean:
- A condition that touches a
NULLcolumn yieldsfalse, not SQL’s “unknown.” - Negation via
NOT EXISTSis exact.
This is why not / cannot behave predictably — no three-valued-logic surprises
leak into your authorization results. Boolean structure (and / or / not)
becomes nested WHERE groups, with negation pushed to the leaves via De
Morgan.
Targeted conditions with no row
Section titled “Targeted conditions with no row”In a no-target check (a capability check, or getUserAbilities() with no target),
a targeted condition has no row to correlate against, so the compiler forces it to
1 = 0 (false) — and, under negation, 1 = 1 (true). Global conditions still
evaluate normally. This is the same rule that makes an absent optional
@context key soft-false its condition.
Worked examples
Section titled “Worked examples”The examples below use a documents schema whose conditions
emit this SQL (the body of each condition method):
| Condition | SQL it adds |
|---|---|
is_self (targeted) |
documents.user_id = ? |
manages_team (targeted) |
documents.team_id in (?, ?) |
is_locked (targeted) |
documents.locked = 1 |
is_admin (global) |
? = ? — the string 'admin' vs. the user’s role |
The SQL below is real compiler output, with redundant nested parentheses trimmed
and ? placeholders annotated with their bound values.
An unconditional grant — they can view — is an always-true term:
select * from documents where (1 = 1)A single targeted condition — if is_self they can view — becomes one
EXISTS. The condition’s documents.user_id correlates to the outer row, so the
subquery is true exactly for the rows the user owns:
select * from documentswhere exists ( select 1 from (select 1) as warrant_exists where documents.user_id = ? -- ? → the current user's id)A global condition — if is_admin they can delete — compiles the same way,
but its EXISTS doesn’t reference the row (it’s true or false for the whole
request):
select * from documentswhere exists ( select 1 from (select 1) as warrant_exists where ? = ? -- 'admin' = the user's role)No can rule for the ability, or an unconditional cannot, is a hard
1 = 0 — even alongside a grant (they can view + they cannot view), because
deny always wins:
select * from documents where (1 = 0)The full opener — three rules for update:
if is_self or manages_team they can updateif is_locked and not is_admin they cannot updateif is_admin they can *compiles to one predicate: an OR of every can source, ANDed with the negated
cannot:
select * from documents where ( -- grant side: the two-part first rule, OR the wildcard `is_admin` rule exists (select 1 from (select 1) as warrant_exists where documents.user_id = ?) -- is_self or exists (select 1 from (select 1) as warrant_exists where documents.team_id in (?, ?)) -- manages_team or exists (select 1 from (select 1) as warrant_exists where ? = ?) -- is_admin (from `they can *`) ) and ( -- deny side: NOT(is_locked and not is_admin), De-Morgan'd onto the leaves not exists (select 1 from (select 1) as warrant_exists where documents.locked = 1) -- not is_locked or exists (select 1 from (select 1) as warrant_exists where ? = ?) -- or is_admin )-- bindings: [user_id, team_a, team_b, 'admin', role, 'admin', role]The cannot update guarded by is_locked and not is_admin becomes
NOT(is_locked AND NOT is_admin), which De Morgan turns into
(NOT is_locked OR is_admin) — negation always lands on the EXISTS leaves, never
on a group, so it stays a strict two-valued boolean.
Several abilities combine per the match mode.
hasAbility(['view', 'update'], matchMode: ALL) ANDs the two per-ability
predicates (ANY would OR them):
select * from documents where exists (select 1 from (select 1) as warrant_exists where documents.user_id = ?) -- view: is_self and exists (select 1 from (select 1) as warrant_exists where ? = ?) -- update: is_adminPer-row abilities (selectAbilities)
run the same per-ability predicates as a correlated subquery per row — one
SELECT ? as ability WHERE <predicate> UNION ALL branch per requested ability,
aggregated into a JSON array:
select *, ( select coalesce(json_group_array(ability), json_array()) from ( select ? as ability where exists (select 1 from (select 1) as warrant_exists where documents.user_id = ?) -- view union all select ? as ability where exists (select 1 from (select 1) as warrant_exists where ? = ?) -- delete ) as available_abilities) as abilitiesfrom documentsEach row’s abilities column ends up holding just the abilities whose predicate
held for that row — e.g. ["view"] for a document the user owns but can’t
delete. (The JSON aggregate differs by driver — see below.)
The three questions, one compiler
Section titled “The three questions, one compiler”- “Which rows?” — the predicates become your query’s
WHERE(hasAbility). - “What can they do to each row?” — the predicates run as correlated
subqueries producing a JSON column
(
selectAbilities). - “Can they?” — the predicate runs as a scoped
EXISTS(userHasAbilities).
Because everything is one compiler, the three can never disagree.
Per-row aggregation is driver-specific
Section titled “Per-row aggregation is driver-specific”The selectAbilities JSON column uses each database’s native JSON aggregate:
| Driver | Aggregate |
|---|---|
| PostgreSQL | coalesce(json_agg(...), '[]'::json) |
| MySQL / MariaDB | coalesce(json_arrayagg(...), json_array()) |
| SQLite | coalesce(json_group_array(...), json_array()) |
Any other driver throws at query-build time. The subquery builds one UNION ALL
branch per ability, which is why
narrowing with onlyAbilities is a
real cost saving on wide lists.
Validation happens at compile time
Section titled “Validation happens at compile time”Compilation validates every ability and condition name against the schema; an unknown name is a hard error, so a typo in a stored rule fails loudly rather than silently granting or denying. Context-key references are validated the same way. See Errors & exceptions.
