Cross-schema checks
A rule normally asks questions of its own schema: if is_author they can update
tests a condition the schema declares, about the row being checked. Sometimes the
answer lives somewhere else — a timesheet is editable because its pay period
is open, a document is visible because the user can view its folder.
The rule language has two builtins for that, and they are the only way a rule reaches across a schema boundary:
| Builtin | Asks the other schema for | Consults the other schema’s rules? |
|---|---|---|
can(<ability> for <handle>) |
permission — does this user hold that ability there? | yes |
check(<predicate> for <handle>) |
domain state — do those conditions hold there? | no |
Both are expressions: they sit anywhere a condition may sit inside an if, and
combine with and, or, not, and parentheses like anything else.
# permission delegated to the folderif can(view for folders(@column documents.folder_id)) they can view
# domain state delegated to the pay periodif check(is_open and not is_locked for pay_periods(@column timesheets.pay_period_id))they can submitThroughout this page A is the schema the rule belongs to, and B the schema being referenced.
can(...) — delegate permission
Section titled “can(...) — delegate permission”can(<ability> for <handle>) is true when the current user holds <ability>
on B. B’s rule set is resolved for that same user and compiled in place, so B’s
whole policy — every can, every cannot, its conditions — decides the answer.
if can(approve for pay_periods(@context period_id)) they can submitThis is the tool for “they may do this here because they may do that there”. Because it re-enters another schema’s rules, it is also the one with a cycle guard (see Cycles and depth).
check(...) — delegate a domain question
Section titled “check(...) — delegate a domain question”check(<predicate> for <handle>) evaluates a boolean expression whose leaves are
B’s own conditions. It never looks at B’s rules and never asks about
permission — it asks about the state of a row:
if check(is_open for pay_periods(@context period_id)) they can submitThe predicate is a full boolean expression, so and, or, not, parentheses,
and condition arguments all work, resolved against B’s vocabulary:
if check((is_open or is_grace_period) and not is_locked for pay_periods(@context period_id))they can submitA predicate is read against B’s vocabulary, and it is a full expression, not
just a list of conditions. It may nest another check(...), whose handle is read
in B’s frame, and it may hold a can(...), which asks about one of B’s abilities:
if check( is_open and check(is_active for tenants(@column tenant_id)) for pay_periods(@column pay_period_id)) they can submitThat reads “this timesheet’s pay period is open, and that pay period’s tenant is
active” — the inner @column tenant_id is resolved in the pay period’s frame, not
the timesheet’s, which is what makes nesting worth having.
What a predicate may not hold is a constant: one that decides itself asks B nothing.
The handle: one row, or the schema itself
Section titled “The handle: one row, or the schema itself”Both builtins take the same handle after for — a schema key, optionally
followed by a row selector:
can(view for folders(@context folder_id)) # row-bound: one specific foldercan(access for billing) # unbound: the schema as a whole- Row-bound —
schema(<row>)names one row of B. B is compiled against that row, so B’s row conditions have something to run against. - Unbound — the bare
schemaform asks B a question with no row at all, exactly like a no-target check. B’s row conditions have nothing to run against, so they are unanswerable — they neither grant nor lift a deny — which makes this form one for capability schemas and global conditions.
An unbound check(...) behaves the same way, and a predicate may freely mix the
two kinds of condition — a row condition among them is simply unanswerable, while
its siblings answer as usual:
if check(tenant_ok or is_open for pay_periods) they can viewtenant_ok is global and decides what it can; is_open reads a column and has
no row here, so it contributes an unknown. Nothing is rejected, and you never have
to remember which of a schema’s conditions are row conditions and which are
global — that is the schema’s own business, and it is free to change.
A row-bound handle requires B to be model-backed. A capability schema has no table and no rows, so a row selector on one is rejected.
Row selectors
Section titled “Row selectors”The value inside schema(<row>) identifies one row of B. It is bound into
where <b_table>.<key> = ?, so it must be something the database can compare
against a key: a scalar, B’s own model, a BackedEnum, a DateTimeInterface,
or a @column / @sql reference (spliced as SQL rather than bound). See
what a row selector may be
for the full table and the reasoning.
The two references worth calling out here:
# correlate B against A's row — the outer table is in scope inside the subqueryif check(is_open for pay_periods(@column timesheets.pay_period_id)) they can view
# name the row at check timeif can(view for folders(@context folder_id)) they can viewAn explicit null row selector — a null literal, or a :name binding that
resolved to null — is rejected, not treated as “no row”:
A can(...) reference to schema [folders] specifies a row target that is null;supply a row id or a @context reference, or drop the row selector.That is deliberate. A $folder?->id that came back null should fail loudly rather
than quietly widen a question about one row into a question about the schema.
Naming a handle’s rows with as
Section titled “Naming a handle’s rows with as”A handle’s rows can be given a name:
if can(view for documents(@column parent_id) as parent) they can viewTwo things follow from it.
In the emitted SQL that name becomes the subquery’s alias — from "documents" as "parent" — which is worth having when the same table appears more than once in
one query, since the correlation is then readable rather than a numbered guess.
You never have to supply one: two frames over one table always get distinct
identifiers, and an unnamed one takes the table’s own name or, when that is
already spoken for, the same name with a numeric suffix.
On a check(...) it is also a name the predicate can use. Naming the inner
frame leaves the target’s schema key still meaning the enclosing one, which is the
only way a predicate can compare two frames of the same table:
if check(owner_matches(@column documents.owner_id) for documents(@column parent_id) as parent)they can viewAn alias needs a row to name, so it is only valid on a row-bound handle.
can(<ability>) — no boundary at all
Section titled “can(<ability>) — no boundary at all”Leave the for clause off and nothing is crossed. It asks about another ability
of this schema, over the row this rule is already about:
for documents { if is_owner they can read if can(read) they can comment if can(comment) they can share}Because there is no boundary, there is nothing to declare across one: the
check-time context comes along unchanged, and the form takes no with map and no
as. It also emits no subquery — the named ability’s predicate is compiled
straight into the frame the reference sits in, so the whole chain above collapses
to a single documents.owner_id = ?.
The with map: context across the boundary
Section titled “The with map: context across the boundary”B never inherits A’s check-time context. Whatever bag the
check was made with belongs to A; B is handed a fresh one, built only from an
explicit with map:
if can(view for folders(@context folder_id) with tenant_id = @context tenant_id)they can viewEach key names one of B’s context keys; each value is resolved in A’s frame —
a literal, a binding, @context, @column, or @sql — and lands in B’s bag under
that key. Duplicate keys in one map are a syntax error.
# A's `region` becomes B's `scope`; B's rules read @context scopeif check(in_scope(@context scope) for folders(@context folder_id) with scope = @context region)they can viewCombining and negating
Section titled “Combining and negating”Both builtins are ordinary leaves of the if expression:
if is_author and check(is_open for pay_periods(@column timesheets.pay_period_id)) and not can(freeze for pay_periods(@context period_id))they can submitNegation follows the usual De Morgan rule and lands on
the leaf, so a negated row-bound reference becomes not exists (...). The same
happens under a cannot:
if not check(is_open for pay_periods(@context period_id))they cannot submit because 'That pay period is closed.'Reachability treats a rule containing either builtin like
any other conditional rule — it is structural, so a can(...) never turns into
ALWAYS or NEVER on its own.
What it compiles to
Section titled “What it compiles to”A row-bound reference becomes an EXISTS over B’s table, keyed to the
selector, with B’s compiled predicate inside it:
if can(view for folders(@context folder_id)) they can viewselect * from "documents" where ( exists ( select * from "folders" where "folders"."id" = 'f-1' and (folders.owner = 'role-1') ))Under negation the same subquery is emitted as not exists (...). Two references
to the same schema on sibling branches produce two independent EXISTS clauses.
An unbound reference has nothing to correlate, so B’s boolean tree is spliced
inline into A’s predicate rather than wrapped in a subquery — which lets a B
that decides outright (a global condition returning a bool) fold into A’s
predicate instead of stopping at a literal.
Handing over the row itself
Section titled “Handing over the row itself”When the row selector is a hydrated model of B rather than a key, the EXISTS
can disappear altogether. The subquery only ever asked two things — does that row
exist, and does B allow it — and a hydrated model has already settled the first.
B’s row conditions receive it as $c->model and may
answer in PHP;
if the whole of B folds to a constant, the reference becomes that constant and no
subquery is built:
if can(view for folders(@context folder)) they can view$user->warrant()->can('view', $document, ['folder' => $folder]);A key alone cannot do this — whether the row exists is exactly what a key has not established. See How it compiles.
Building them with the fluent builder
Section titled “Building them with the fluent builder”Row selectors and with values are usually runtime values, which is where the
rule builder tends to read
better than a string:
use Warrant\Builders\Ref;use Warrant\Rules\WarrantRule;
WarrantRule::build() ->if('is_author') ->orIfCan('approve', PayPeriod::class, Ref::context('period_id')) ->andIfCheck( fn ($p) => $p->if('is_open')->andIfNot('is_locked'), 'pay_periods', Ref::column('pay_period_id'), ) ->theyCan('submit') ->toRule();The two things to remember: omitting $row gives the unbound handle (its
default is a NoRow sentinel, not null), and there are no negated variants —
negate with a group, ->ifNot(fn ($c) => $c->ifCan(...)). Full signatures are in
the rule-building API.
Cycles and depth
Section titled “Cycles and depth”Because can(...) compiles B’s rules, and B’s rules may reference C, a chain can
close on itself. The compiler tracks the (schema, ability) frames on the current
path and throws when one repeats:
Cross-schema can(...) cycle detected: timesheets:create → pay_periods:approve →timesheets:create. A can(...) reference must not, directly or transitively, dependon the ability being compiled.The guard is path-scoped, so two sibling references to the same schema are fine — only re-entering a frame already on the path is a cycle. Nesting is also capped at a depth of 32.
A check(...) dispatch touches no rules of its own, so the dispatch itself
cannot close a loop. A can(...) inside its predicate can, and is guarded by the
same (schema, ability) frames — the check adds a layer to the depth budget
either way.
Restrictions, in one place
Section titled “Restrictions, in one place”Validation-time (InvalidArgumentException, when the rule set is validated or
compiled against its schema):
- The target schema must be registered, and for
can(...)it must declare the ability. A reference may target its own schema. - A row-bound handle needs a model-backed target; a capability schema cannot be row-targeted.
- A row selector that resolves to a literal
nullis rejected. One that resolves to nothing at check time — an absent@context— is unanswerable rather than rejected; see How it compiles. - An
as <alias>needs a row to name; an unbound handle selects none. - A
check(...)predicate is read against the target’s vocabulary, and may hold that schema’s conditions, acan(...), and a nestedcheck(...). It may not hold a constant, and on an unbound handle it may not hold a row condition. - A
can(<ability>)with noforclause takes nowithmap and noas— it crosses nothing, so there is nothing to hand over or to name. - A
@columnreference must name a table in scope where it is written.
Compile-time (InvalidArgumentException / RuntimeException):
- A row selector that is a model of the wrong schema, or any other object with no meaning as a row key, is rejected rather than silently matching nothing — see Cross-schema row selectors.
- B must be on the same database connection as the query being filtered; a subquery cannot reach across connections.
- A cycle, or nesting deeper than 32.
Grammar
Section titled “Grammar”can_ref = "can" "(" IDENTIFIER ( "for" handle ( "with" with_map )? )? ")" ;check_ref = "check" "(" expr "for" handle ( "with" with_map )? ")" ;handle = IDENTIFIER ( "(" arg ")" )? ( "as" IDENTIFIER )? ;with_map = IDENTIFIER "=" arg ( "," IDENTIFIER "=" arg )* ;arg is the same argument production the rule
language uses everywhere else — literals,
:name / ? bindings, @context, @column, and @sql.
