Skip to content

Check-time context

Some values a rule needs aren’t known when the schema is written or when the resolver builds the rules — they’re known only at the moment of the check: the current tenant, an academic year, an as-of date, an impersonated user. These are context keys.

Declare each with #[ContextKey], mirroring #[Ability] — the constant’s value is the key string; its name is irrelevant to Warrant:

use Warrant\ContextKey;
// Required by default: no check on this resource resolves without the frame.
#[ContextKey] public const WORKSPACE = 'workspace_id';
// Opt out for a frame that only gates grants.
#[ContextKey(required: false)] public const AS_OF = 'as_of_date';
DocumentSchema::declaredContextKeys(); // ['workspace_id', 'as_of_date']
DocumentSchema::requiredContextKeys(); // ['workspace_id']

A rule references a key with @context <key>; the value is threaded into $c->arguments positionally at check time:

if in_workspace(@context workspace_id) they can view, edit
#[TargetedCondition]
public function inWorkspace(TargetedConditionContext $c): Builder
{
[$workspace] = $c->arguments; // supplied at the check via @context workspace_id
return $c->query->where('documents.workspace_id', $workspace);
}

An undeclared @context reference is a compile-time error, exactly like an unknown condition name. 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

Every condition also receives the full effective context on $c->context, whether or not the rule passed a value via @context. Reach into it directly when a condition is inherently tied to the frame — then the rule needn’t mention the key:

#[TargetedCondition]
public function inCurrentWorkspace(TargetedConditionContext $c): Builder
{
// Rule is just `if in_current_workspace they can view` — no @context needed.
return $c->query->where('documents.workspace_id', $c->context['workspace_id']);
}

Two styles, same value. @context threads a key positionally and soft-falses the condition when an optional key is missing; $c->context hands every condition the whole bag to read however it likes. Pick whichever makes your rules read the way you want.

Every check API takes an optional context: array. It threads through the model helpers, the query scopes, and the capability checks alike:

// Boolean check:
Document::userHasAbilities('update', $document, context: ['workspace_id' => $id]);
// Row filtering:
Document::query()->hasAbility('update', context: ['workspace_id' => $id])->paginate();
// Per-row abilities, evaluated in one fixed frame:
Document::query()->selectAbilities(context: ['workspace_id' => $id])->get();

Whatever you pass is merged over defaultContext(), with explicit values winning (a partial merge — you can override just one key).

defaultContext() supplies defaults so callers may omit a key — and so param-less paths (route middleware, the SelectAbilities global scope) get a frame with no context: argument:

protected function defaultContext(): array
{
return ['workspace_id' => app('tenant')->id];
}

A default can satisfy a required key, so a required key with a default never throws.

Keys are required by default. Any check on the schema throws unless the key is present in the effective context (explicit + defaults):

Schema [...] requires context key(s) [workspace_id]; supply them at the check
or via defaultContext().

That loud failure is a feature — a required frame is never silently skipped.

Opt out with #[ContextKey(required: false)] only for a frame that never gates a cannot. Here’s why:

When an optional @context key is absent at check time, its condition becomes unevaluable and is treated as false — the same rule Warrant applies to a targeted condition in a no-target check (and, by De Morgan, not <it> becomes true).

  • On a grant (can), false is safe: no key, no grant (fail-closed).
  • On a deny (cannot), false means the veto doesn’t apply — the deny lifts (fail-open).

So a missing optional key can silently remove a restriction. That’s exactly what required: true forecloses. When in doubt, leave it required.

# If workspace_id is optional and absent, this cannot LIFTS — the user is no
# longer blocked. Declare workspace_id required to prevent that.
if outside_workspace(@context workspace_id) they cannot view