Access-explanation contract — explain(principal, object, operation) as a first-class API.
[ADR-0090 D6] Access-explanation contract — explain(principal, object, operation) as a first-class API.
The explain engine (@objectstack/plugin-security) walks the SAME code
paths as the enforcement middleware — the same permission-set resolution,
the same evaluator, the same RLS compiler — and reports what each layer of
the evaluation pipeline contributed to the final decision. "Explained by
construction": the report can never drift from enforcement because it IS
enforcement, minus the throw.
Layer order mirrors the runtime pipeline:
tenant_isolation → principal → required_permissions → object_crud → fls →
owd_baseline → depth → sharing → vama_bypass → rls.
[C2 / ADR-0095] Record-grained explanation. The contract carries an optional
recordId on the request and, when present, a per-layer record attribution
plus a top-level record verdict on the response — so the sharing / rls / owd
layers can report the ROW-LEVEL story for one concrete record (which share
admitted it, which filter excluded it, whether the effective row filter
matches). Object-level requests (no recordId) stay byte-compatible.
[ADR-0095 D1/D2] The contract also reserves the kernel-chain vocabulary the
β engine + UI will fill: the always-first tenant wall as tenant_isolation
(Layer 0), a per-layer kernelTier marking Layer 0 vs. business RLS
(Layer 1), and the monotonic posture ladder
(PLATFORM_ADMIN > TENANT_ADMIN > MEMBER > EXTERNAL) on the resolved principal.
ADR-0095 D2 posture rung — PLATFORM_ADMIN crosses the tenant wall where object posture permits; TENANT_ADMIN sees all rows in the org; MEMBER gets business RLS; EXTERNAL sees only explicitly shared rows.
The composed row filter the caller would be served with — the machine artifact behind the prose (null = unrestricted). Two shapes mean zero rows — { id: "__deny_all__" } and the fail-closed RLS denial (__rls_deny__ plus a colon and a UUID-shaped suffix, which can also ride inside an $and composite) — so a consumer pattern-matching this payload alone must match both; the decision itself is allowed plus the rls layer verdict.
Row-level verdict for the specific record; set only for singular record-grained requests. An id the asker (the caller of explain) cannot read answers as missing, like a filtered, deleted or never-existed id: visible false, decidedBy omitted. Explaining another principal, the asker cannot read an id its own by-id read finds no row for or refuses; a principal explaining itself cannot read an id its read finds no row for.
ADR-0095 D2 posture rung — PLATFORM_ADMIN crosses the tenant wall where object posture permits; TENANT_ADMIN sees all rows in the org; MEMBER gets business RLS; EXTERNAL sees only explicitly shared rows.
This layer's row-level outcome for the record: admitted, excluded, or not_evaluated (skipped/not row-scoped).
rowFilter
any
optional
The effective row predicate this layer contributed for the record set, published as composed (null = unrestricted). Two shapes mean zero rows — { id: "__deny_all__" } and the fail-closed RLS denial (__rls_deny__ plus a colon and a UUID-shaped suffix) — so a consumer pattern-matching this payload alone must match both; the decision itself is outcome/matchesRecord plus the layer verdict.
matchesRecord
boolean
optional
Whether the specific record satisfies rowFilter — the judgement behind outcome.
This layer's row-level outcome for the record: admitted, excluded, or not_evaluated (skipped/not row-scoped).
rowFilter
any
optional
The effective row predicate this layer contributed for the record set, published as composed (null = unrestricted). Two shapes mean zero rows — { id: "__deny_all__" } and the fail-closed RLS denial (__rls_deny__ plus a colon and a UUID-shaped suffix) — so a consumer pattern-matching this payload alone must match both; the decision itself is outcome/matchesRecord plus the layer verdict.
matchesRecord
boolean
optional
Whether the specific record satisfies rowFilter — the judgement behind outcome.