$-variable references
Compare two fields on the same request - which $-paths resolve, from which object, at what point in evaluation, and what a missing path does to each operator
A condition normally compares a resolved field against a literal. A $-prefixed string value makes the right-hand side a path too, so one condition can compare two parts of the same request - the resource's owner against the subject's id, the resource's department against the subject's department, an expiry against the clock.
The rule in one line
A condition value is resolved as a path only when it is a string whose first character is $. Everything else - numbers, booleans, null, arrays, objects, and strings that do not start with $ - is used literally.
// resource.attributes.ownerId === request.subject.id
.when((w) => w.check('resource.attributes.ownerId', 'eq', '$subject.id'))
// literal: resource.attributes.ownerId === the seven-character string 'subject'
.when((w) => w.check('resource.attributes.ownerId', 'eq', 'subject'))
At evaluation time the engine strips the $, resolves the remainder against the request with the same resolve() used for the field side, and hands both values to the operator. The field side is always a path and never takes a $.
isOwner() is a shorthand for exactly this pattern:
w.isOwner()
// { field: 'resource.attributes.ownerId', operator: 'eq', value: '$subject.id' }
w.isOwner('resource.attributes.createdBy')
// { field: 'resource.attributes.createdBy', operator: 'eq', value: '$subject.id' }
When resolution happens
$ values are resolved per condition, per request, against the request object as it exists at that moment - after subject resolution, after scoped-role enrichment, after your beforeEvaluate hook, and after the engine has defaulted environment.now.
Three things follow from the ordering in the diagram:
$subject.rolesis the effective role set, not the raw assignment: inheritance is already closed over and matching scoped roles are already merged in. See role inheritance and scoped roles.beforeEvaluatecan change what a<MathMl mathml="<span class="katex"><span class="katex-mathml"><math xmlns="http://www.w3.org/1998/Math/MathML"><semantics><mrow><mi mathvariant="normal">‘</mi><mi>p</mi><mi>a</mi><mi>t</mi><mi>h</mi><mi>s</mi><mi>e</mi><mi>e</mi><mi>s</mi><mi mathvariant="normal">.</mi><mo>∗</mo><mo>∗</mo><mi>A</mi><mi>h</mi><mi>o</mi><mi>o</mi><mi>k</mi><mi>t</mi><mi>h</mi><mi>a</mi><mi>t</mi><mi>i</mi><mi>n</mi><mi>j</mi><mi>e</mi><mi>c</mi><mi>t</mi><mi>s</mi><mi mathvariant="normal">‘</mi><mi>e</mi><mi>n</mi><mi>v</mi><mi>i</mi><mi>r</mi><mi>o</mi><mi>n</mi><mi>m</mi><mi>e</mi><mi>n</mi><mi>t</mi><mi mathvariant="normal">.</mi><mi>t</mi><mi>e</mi><mi>n</mi><mi>a</mi><mi>n</mi><mi>t</mi><mi>I</mi><mi>d</mi><mi mathvariant="normal">‘</mi><mi>m</mi><mi>a</mi><mi>k</mi><mi>e</mi><mi>s</mi><mi mathvariant="normal">‘</mi></mrow><annotation encoding="application/x-tex">path sees. A hook that injectsenvironment.tenantIdmakes</annotation></semantics></math></span><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:0.8889em;vertical-align:-0.1944em;"></span><span class="mord">‘</span><span class="mord mathnormal">p</span><span class="mord mathnormal">a</span><span class="mord mathnormal">t</span><span class="mord mathnormal">h</span><span class="mord mathnormal">sees</span><span class="mord">.</span><span class="mspace" style="margin-right:0.2222em;"></span><span class="mbin">∗</span><span class="mspace" style="margin-right:0.2222em;"></span></span><span class="base"><span class="strut" style="height:0.8889em;vertical-align:-0.1944em;"></span><span class="mord">∗</span><span class="mord mathnormal">A</span><span class="mord mathnormal">h</span><span class="mord mathnormal">oo</span><span class="mord mathnormal" style="margin-right:0.03148em;">k</span><span class="mord mathnormal">t</span><span class="mord mathnormal">ha</span><span class="mord mathnormal">t</span><span class="mord mathnormal" style="margin-right:0.05724em;">inj</span><span class="mord mathnormal">ec</span><span class="mord mathnormal">t</span><span class="mord mathnormal">s</span><span class="mord">‘</span><span class="mord mathnormal">e</span><span class="mord mathnormal">n</span><span class="mord mathnormal" style="margin-right:0.03588em;">v</span><span class="mord mathnormal">i</span><span class="mord mathnormal">ro</span><span class="mord mathnormal">nm</span><span class="mord mathnormal">e</span><span class="mord mathnormal">n</span><span class="mord mathnormal">t</span><span class="mord">.</span><span class="mord mathnormal">t</span><span class="mord mathnormal">e</span><span class="mord mathnormal">nan</span><span class="mord mathnormal">t</span><span class="mord mathnormal" style="margin-right:0.07847em;">I</span><span class="mord mathnormal">d</span><span class="mord">‘</span><span class="mord mathnormal" style="margin-right:0.03148em;">mak</span><span class="mord mathnormal">es</span><span class="mord">‘</span></span></span></span>"/>environment.tenantIdresolvable for every policy in that request.$environment.nowalways resolves. The engine setsenvironment.nowtoDate.now()in epoch milliseconds when the caller did not supply one, and never overwrites a supplied value - so a test or a replay hook can pin the clock. This is what pairs with thebeforeandafteroperators.
Nothing is resolved at build time. definePolicy(...).build() stores the literal string '$subject.id'; the same policy JSON works for every request.
Which paths resolve
The $ side accepts the same roots as the field side.
$-path | Resolves to | Notes |
|---|---|---|
$action | request.action | Whole-path shorthand, not a root |
$scope | request.scope, or null when the request has no scope | Whole-path shorthand |
$subject.id | The subject id | |
$subject.roles | The effective roles array | An array value - see "Arrays" below |
$subject.attributes.<key> | A subject attribute, at any depth | |
<MathMl mathml="<span class="katex"><span class="katex-mathml"><math xmlns="http://www.w3.org/1998/Math/MathML"><semantics><mrow><mi>s</mi><mi>u</mi><mi>b</mi><mi>j</mi><mi>e</mi><mi>c</mi><mi>t</mi><mi mathvariant="normal">.</mi><mi>s</mi><mi>c</mi><mi>o</mi><mi>p</mi><mi>e</mi><mi>d</mi><mi>R</mi><mi>o</mi><mi>l</mi><mi>e</mi><mi>s</mi><mi mathvariant="normal">.</mi><mo>&lt;</mo><mi>i</mi><mo>&gt;</mo><mi mathvariant="normal">.</mi><mi>a</mi><mi>t</mi><mi>t</mi><mi>r</mi><mi>i</mi><mi>b</mi><mi>u</mi><mi>t</mi><mi>e</mi><mi>s</mi><mi mathvariant="normal">.</mi><mo>&lt;</mo><mi>k</mi><mi>e</mi><mi>y</mi><mo>&gt;</mo><mi mathvariant="normal">‘</mi><mi mathvariant="normal">∣</mi><mi>A</mi><mi>p</mi><mi>e</mi><mi>r</mi><mo>−</mo><mi>a</mi><mi>s</mi><mi>s</mi><mi>i</mi><mi>g</mi><mi>n</mi><mi>m</mi><mi>e</mi><mi>n</mi><mi>t</mi><mi>a</mi><mi>t</mi><mi>t</mi><mi>r</mi><mi>i</mi><mi>b</mi><mi>u</mi><mi>t</mi><mi>e</mi><mi mathvariant="normal">∣</mi><mi>T</mi><mi>h</mi><mi>e</mi><mi>b</mi><mi>a</mi><mi>r</mi><mi>e</mi><mi mathvariant="normal">‘</mi></mrow><annotation encoding="application/x-tex">subject.scopedRoles.&lt;i&gt;.attributes.&lt;key&gt; | A per-assignment attribute | The bare </annotation></semantics></math></span><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:0.8889em;vertical-align:-0.1944em;"></span><span class="mord mathnormal">s</span><span class="mord mathnormal">u</span><span class="mord mathnormal" style="margin-right:0.05724em;">bj</span><span class="mord mathnormal">ec</span><span class="mord mathnormal">t</span><span class="mord">.</span><span class="mord mathnormal">sco</span><span class="mord mathnormal">p</span><span class="mord mathnormal">e</span><span class="mord mathnormal">d</span><span class="mord mathnormal" style="margin-right:0.00773em;">R</span><span class="mord mathnormal">o</span><span class="mord mathnormal" style="margin-right:0.01968em;">l</span><span class="mord mathnormal">es</span><span class="mord">.</span><span class="mspace" style="margin-right:0.2778em;"></span><span class="mrel">&lt;</span><span class="mspace" style="margin-right:0.2778em;"></span></span><span class="base"><span class="strut" style="height:0.6986em;vertical-align:-0.0391em;"></span><span class="mord mathnormal">i</span><span class="mspace" style="margin-right:0.2778em;"></span><span class="mrel">&gt;</span><span class="mspace" style="margin-right:0.2778em;"></span></span><span class="base"><span class="strut" style="height:0.7335em;vertical-align:-0.0391em;"></span><span class="mord">.</span><span class="mord mathnormal">a</span><span class="mord mathnormal">tt</span><span class="mord mathnormal" style="margin-right:0.02778em;">r</span><span class="mord mathnormal">ib</span><span class="mord mathnormal">u</span><span class="mord mathnormal">t</span><span class="mord mathnormal">es</span><span class="mord">.</span><span class="mspace" style="margin-right:0.2778em;"></span><span class="mrel">&lt;</span><span class="mspace" style="margin-right:0.2778em;"></span></span><span class="base"><span class="strut" style="height:0.8889em;vertical-align:-0.1944em;"></span><span class="mord mathnormal" style="margin-right:0.03148em;">k</span><span class="mord mathnormal" style="margin-right:0.03588em;">ey</span><span class="mspace" style="margin-right:0.2778em;"></span><span class="mrel">&gt;</span><span class="mspace" style="margin-right:0.2778em;"></span></span><span class="base"><span class="strut" style="height:1em;vertical-align:-0.25em;"></span><span class="mord">‘∣</span><span class="mord mathnormal">A</span><span class="mord mathnormal">p</span><span class="mord mathnormal" style="margin-right:0.02778em;">er</span><span class="mspace" style="margin-right:0.2222em;"></span><span class="mbin">−</span><span class="mspace" style="margin-right:0.2222em;"></span></span><span class="base"><span class="strut" style="height:1em;vertical-align:-0.25em;"></span><span class="mord mathnormal">a</span><span class="mord mathnormal">ss</span><span class="mord mathnormal">i</span><span class="mord mathnormal" style="margin-right:0.03588em;">g</span><span class="mord mathnormal">nm</span><span class="mord mathnormal">e</span><span class="mord mathnormal">n</span><span class="mord mathnormal">t</span><span class="mord mathnormal">a</span><span class="mord mathnormal">tt</span><span class="mord mathnormal" style="margin-right:0.02778em;">r</span><span class="mord mathnormal">ib</span><span class="mord mathnormal">u</span><span class="mord mathnormal">t</span><span class="mord mathnormal">e</span><span class="mord">∣</span><span class="mord mathnormal" style="margin-right:0.13889em;">T</span><span class="mord mathnormal">h</span><span class="mord mathnormal">e</span><span class="mord mathnormal">ba</span><span class="mord mathnormal">re</span><span class="mord">‘</span></span></span></span>"/>subject.scopedRoles is null - an array of objects is outside AttributeValue |
<MathMl mathml="<span class="katex"><span class="katex-mathml"><math xmlns="http://www.w3.org/1998/Math/MathML"><semantics><mrow><mi>r</mi><mi>e</mi><mi>s</mi><mi>o</mi><mi>u</mi><mi>r</mi><mi>c</mi><mi>e</mi><mi mathvariant="normal">.</mi><mi>t</mi><mi>y</mi><mi>p</mi><mi>e</mi><mi mathvariant="normal">‘</mi><mi mathvariant="normal">/</mi><mi mathvariant="normal">‘</mi></mrow><annotation encoding="application/x-tex">resource.type / </annotation></semantics></math></span><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:1em;vertical-align:-0.25em;"></span><span class="mord mathnormal">reso</span><span class="mord mathnormal">u</span><span class="mord mathnormal">rce</span><span class="mord">.</span><span class="mord mathnormal">t</span><span class="mord mathnormal" style="margin-right:0.03588em;">y</span><span class="mord mathnormal">p</span><span class="mord mathnormal">e</span><span class="mord">‘/‘</span></span></span></span>"/>resource.id | Resource type and instance id | |
$resource.attributes.<key> | A resource attribute, at any depth | |
$environment.<key> | ip, userAgent, timestamp, now, or any custom key |
Resolution rules:
- The only permitted roots are
subject,resource, andenvironment, plus the two shorthands.$nope.xresolves tonull- not to the literal string. __proto__,constructor, andprototypeare refused at any segment; the whole path resolves tonull.$__proto__.xisnull.- Traversal stops at the first non-object and
undefinedbecomesnull.$resource.attributes.missingisnull. - The resolved value is narrowed, not asserted. It must be a scalar, an array of scalars, or a flat object of scalars; anything else - a
Date, a nested object, an array of objects - becomesnull. So<MathMl mathml="<span class="katex"><span class="katex-mathml"><math xmlns="http://www.w3.org/1998/Math/MathML"><semantics><mrow><mi>s</mi><mi>u</mi><mi>b</mi><mi>j</mi><mi>e</mi><mi>c</mi><mi>t</mi><mi mathvariant="normal">‘</mi><mi>a</mi><mi>n</mi><mi>d</mi><mi mathvariant="normal">‘</mi></mrow><annotation encoding="application/x-tex">subjectand</annotation></semantics></math></span><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:0.8889em;vertical-align:-0.1944em;"></span><span class="mord mathnormal">s</span><span class="mord mathnormal">u</span><span class="mord mathnormal" style="margin-right:0.05724em;">bj</span><span class="mord mathnormal">ec</span><span class="mord mathnormal">t</span><span class="mord">‘</span><span class="mord mathnormal">an</span><span class="mord mathnormal">d</span><span class="mord">‘</span></span></span></span>"/>resourcearenull, not the object. - Path splitting is memoised in a FIFO cache of
PATH_CACHE_MAX(10_000) entries, per engine instance when the engine supplies one.
// against a request with subject u1 (roles editor, admin; department eng),
// resource post p1 (ownerId u1), action 'update', no scope:
'$subject.id' // 'u1'
'$subject.roles' // ['editor', 'admin']
'$subject.attributes.department' // 'eng'
'$resource.attributes.missing' // null
'$action' // 'update'
'$scope' // null (request has no scope)
'$nope.x' // null (unknown root)
'$__proto__.x' // null (blocked segment)
'$subject' // null (an object with a roles array is not an AttributeValue)
'subject.id' // 'subject.id' (no $, so a literal)
Where $ values are accepted
Anywhere the builder takes a value: check(), eq(), neq(), in(), gt/gte/lt/lte, contains(), and the attribute shorthands attr(), resourceAttr(), env(). The builder does not treat them specially - it stores the string, and the evaluator resolves it.
// Resource owner must not be the subject (four-eyes review)
.when((w) => w.resourceAttr('ownerId', 'neq', '$subject.id'))
// Subject and resource must share a department
.when((w) => w.check('resource.attributes.department', 'eq', '$subject.attributes.department'))
// Subject's clearance must reach the document's level
.when((w) => w.check('subject.attributes.clearance', 'gte', '$resource.attributes.classificationLevel'))
// The request scope must be the resource's tenant
.when((w) => w.check('resource.attributes.orgId', 'eq', '$scope'))
// Grant is still in the future
.when((w) => w.check('subject.attributes.suspendedUntil', 'after', '$environment.now'))
Two operators refuse or ignore the $ form:
matchesrefuses it. Amatchescondition whose value starts with$throwsIamUserSourcedPatternErrorbefore the reference is resolved, so no attacker-controlled attribute can ever supply the regex. It throws rather than answeringfalsebecausefalsereads as "condition not met": measured throughengine.canon a seeded policy,deny when email matches $resource.attributes.bannedPatternnever fired and the banned subject was allowed, with nothing reported toonPolicyError. Details on conditions.- Array literals are not resolved element by element.
w.in('resource.attributes.ownerId', ['$subject.id'])compares against the literal seven-character string'$subject.id', not the subject id, and is therefore always false. Only a whole value that is a$-string is resolved.
A $ path that resolves to an array does work with the set operators, because the resolved value is a real array:
// true when subject.attributes.tier is one of the subject's own roles
.when((w) => w.check('subject.attributes.tier', 'in', '$subject.roles'))
// true when any of the subject's roles is in the resource's allow-list
.when((w) => w.check('subject.roles', 'in', '$resource.attributes.allowedRoles'))
The second form is the useful one: with an array on both sides, in is an overlap test.
A $ path that resolves to nothing is refused
resolve() never throws: an unknown root, a blocked segment, a typo, or an attribute the request did not carry all produce null. What happens next depends on which side of the condition the null is on, and the two sides are treated differently on purpose.
On the field (left-hand) side, null is a match failure. No guard runs; each operator's own typeof test decides, and most answer false. The four negated operators (neq, nin, not_contains, not_exists) answer true. That table is on conditions.
On the operand (right-hand) side, a $-reference that resolves to null is a refusal. evalCondition throws IamOperandTypeError naming the field, the operator, and the reference that resolved to nothing. The engine reports it through hooks.onPolicyError and the policy becomes Indeterminate: it still votes, and a policy carrying any deny rule votes deny. Under the default policyCombine: 'and' that deny is final.
This is what fixed the canonical multi-tenant guard. subject.attributes.tenant eq $resource.attributes.tenant used to compare null === null and allow a request that carried neither attribute - through the fully validated authoring path, since the validator cannot type a $-reference and so had nothing to say about it. The refusal is scoped to $-references on purpose: a literal value: null is an author explicitly testing for null and still works.
Two consequences worth planning for:
A
$-comparison against optional data now takes the whole policy Indeterminate, rather than granting silently. If$subject.attributes.tenantIdis genuinely optional, guard the comparison withexistson both sides so the rule declines to match instead of refusing:.when((w) => w .exists('resource.attributes.tenantId') .exists('subject.attributes.tenantId') .check('resource.attributes.tenantId', 'eq', '$subject.attributes.tenantId'), )The
allgroup short-circuits at the first falseexists, so the refusing leaf is never reached.$environment.nowneeds the engine. The engine injectsenvironment.now = Date.now()afterbeforeEvaluate, so a hook-pinned clock survives. A request built by hand and passed straight toiamEvaluategets no such injection, and a temporal rule against$environment.nowon it throws.
The validator catches typos but not absent data. A $ value whose root is not resolvable produces the warning UNRESOLVABLE_VALUE - Condition value "$foo.bar" references an unresolvable path. Warnings do not make build() throw, so read them: see validation.
Typed $-paths
With a typed context, $ values autocomplete and are checked at compile time. The paths come from DotPath.DollarPaths<TContext>, which prefixes a $ onto every member of DotPath.DotPaths<TContext> - that is, every reachable path through the context object.
import { createIam } from '@gentleduck/iam'
const access = createIam({
actions: ['read', 'update'] as const,
resources: ['post'] as const,
context: {} as {
action: string
scope: string
subject: { id: string; roles: string[]; attributes: { tier: 'free' | 'pro'; department: string } }
resource: { type: 'post'; id: string; attributes: { ownerId: string; tier: 'free' | 'pro'; department: string } }
environment: { hour: number; now: number }
},
})
const p = access
.definePolicy('post-tier')
.rule('match-tier', (r) =>
r
.allow()
.on('read')
.of('post')
// the editor offers '$subject.attributes.tier' here
.when((w) => w.check('resource.attributes.tier', 'eq', '$subject.attributes.tier')),
)
.build()
Builder methods take DotPath.FlexibleDollarPaths<TContext>, which is DollarPaths<TContext> | (string & {}). The & {} intersection keeps the literal suggestions visible in the editor while still accepting any string, so a path your context does not model is a runtime null rather than a compile error. Two consequences:
- Open attribute bags stop at the bag. With the default
DotPath.IDefaultContextthe attribute bags are index-signature types, soDotPathsstops atsubject.attributesand no per-key suggestions appear. Declare concrete attribute shapes, as above, to get them. - Arrays are leaves.
$subject.rolesis offered;$subject.roles.0is not derived, though it resolves fine at runtime.
Full type machinery on typed dollar-paths and typed context.
Common patterns
Owner-only edits
.rule('owner-edit', (r) =>
r
.allow()
.on('update', 'delete')
.of('post')
.when((w) => w.isOwner()),
)
Same-tenant access, presence-checked
.rule('tenant-isolation', (r) =>
r
.allow()
.on('*')
.of('document')
.when((w) =>
w
.exists('resource.attributes.tenantId')
.check('resource.attributes.tenantId', 'eq', '$subject.attributes.tenantId'),
),
)
Prevent self-actions
.rule('no-self-delete', (r) =>
r
.deny()
.on('delete')
.of('user')
.when((w) => w.check('resource.id', 'eq', '$subject.id')),
)
A deny rule is the right shape here: resource.id is on the field side, so a missing id is a match failure, the deny does not fire, and the request falls back to whatever else the policy set decides. $subject.id on the operand side always resolves, because the engine cannot assemble a request without a subject.
Clearance must reach classification
.rule('insufficient-clearance', (r) =>
r
.deny()
.on('read')
.of('document')
.when((w) =>
w.check('subject.attributes.clearance', 'lt', '$resource.attributes.classificationLevel'),
),
)
The two sides fail differently. A missing or non-numeric subject.attributes.clearance is a field-side match failure, so the deny does not fire. A missing resource.attributes.classificationLevel is an operand-side refusal: lt requires a number, the condition throws, and this policy - which carries a deny rule - votes deny as Indeterminate. Put exists guards ahead of the comparison if you want the rule to decline rather than refuse.
Grant window still open
.rule('suspension-expired', (r) =>
r
.deny()
.on('*')
.of('*')
.when((w) => w.check('subject.attributes.suspendedUntil', 'after', '$environment.now')),
)
API reference
Runtime helpers, exported from @gentleduck/iam and @gentleduck/iam/core:
| Export | Signature | What it does |
|---|---|---|
iamResolveConditionValue | (req: IamRequest.IAccessRequest, value: IamPrimitives.AttributeValue) => IamPrimitives.AttributeValue | Resolves a $-value, passes anything else through |
iamResolveValue | (req, value, caches?) => IamPrimitives.AttributeValue | The same, with an optional per-engine path cache |
resolve | (req, path: string, caches?) => IamPrimitives.AttributeValue | Resolve a bare path (no $); null on any miss |
iamIsUserSourcedValue | (value: IamPrimitives.AttributeValue) => boolean | true only for a $-prefixed string; the guard matches uses |
PATH_CACHE_MAX | 10_000 | Path-segment cache cap |
clearPathCache | () => void | Flush the process-wide path cache |
ALLOWED_ROOTS and the pathCache map are deliberately not exported. ALLOWED_ROOTS is typed ReadonlySet but erases to a live Set, so .delete('subject') would make every subject.* path unresolvable and flip the deny rules that read one - and pathCache memoises that, so it would stick.
import { iamResolveConditionValue } from '@gentleduck/iam'
iamResolveConditionValue(request, '$subject.id') // 'u1'
iamResolveConditionValue(request, 'user-1') // 'user-1'
iamResolveConditionValue(request, 42) // 42
iamResolveConditionValue(request, '$nope.nope') // null
Types:
type DotPath.DollarPaths<TContext> = `$${DotPath.DotPaths<TContext>}`
type DotPath.FlexibleDollarPaths<TContext> = DotPath.DollarPaths<TContext> | (string & {})
Gotchas
- A
$is only special in the first character of a string condition value.'price-$100'is a literal;['$subject.id']is a literal array. $on thefieldside is not a thing - fields are already paths.check('$subject.id', ...)looks for a root named$subject, which is unresolvable, and the validator warnsUNRESOLVABLE_FIELD.eqtakes a scalar operand, so a$path that resolves to an array or object is refused asIamOperandTypeError. Usesubset_ofplussuperset_offor set equality.$scopeisnullfor an unscoped request, socheck('resource.attributes.orgId', 'eq', '$scope')refuses on every unscoped request and takes its policy out of the decision. Guard the rule withexists('scope'), or useforScope()so the rule only applies to scoped requests.- Nothing resolves at build time, so a policy exported to JSON and re-imported behaves identically. See admin export and import.
See also
- Conditions - every operator and its
nullsemantics in full - Nesting and/or/not - grouping presence guards with the comparison
- Typed dollar-paths - how the path union is derived
- Evaluation pipeline - where request assembly and hooks sit
- Layered policy example -
$-variables in a working policy set