From 97f05a04a94448b6fa3a5c7c6749cb246c51044a Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 15:50:11 +0000 Subject: [PATCH] =?UTF-8?q?feat(spec):=20C2-=CE=B1=20=E2=80=94=20explain?= =?UTF-8?q?=20=E5=A5=91=E7=BA=A6=E6=89=A9=E5=B1=95=E5=88=B0=20record=20?= =?UTF-8?q?=E7=B2=92=E5=BA=A6=20(#2920)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extend the ADR-0090 D6 access-explanation contract to record granularity (schema only; no engine or UI changes). Adds optional recordId on the request; per-record attribution (ExplainMatchedRule, ExplainRecordAttribution, the optional ExplainLayer.record, and top-level ExplainDecision.record) on the response; and reserves the ADR-0095 kernel-chain vocabulary for the β engine: the tenant_isolation Layer 0 id, ExplainLayer.kernelTier, and the AuthzPosture ladder on principal. Every new field is optional/additive — object-level requests parse unchanged. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_019QRUvVfpvSycAHMMF2xTxs --- .../authz-c2-explain-record-contract.md | 40 ++++ packages/spec/api-surface.json | 6 + packages/spec/src/security/explain.test.ts | 151 ++++++++++++++- packages/spec/src/security/explain.zod.ts | 175 +++++++++++++++++- 4 files changed, 368 insertions(+), 4 deletions(-) create mode 100644 .changeset/authz-c2-explain-record-contract.md diff --git a/.changeset/authz-c2-explain-record-contract.md b/.changeset/authz-c2-explain-record-contract.md new file mode 100644 index 0000000000..e7fe33ac38 --- /dev/null +++ b/.changeset/authz-c2-explain-record-contract.md @@ -0,0 +1,40 @@ +--- +"@objectstack/spec": minor +--- + +feat(spec): C2-α — extend the `explain` contract to record granularity (#2920) + +The access-explanation contract (ADR-0090 D6) now carries the schema for +record-level authorization explanations, so the β-phase engine +(`plugin-security` + `plugin-sharing`) and the Studio/Setup "view as" UI can be +built against a stable wire shape. Contract-only: no engine or UI changes ship +here. + +Request side: +- `ExplainRequest.recordId` (optional) — explain one concrete record at row + granularity. Omitted = the pre-C2 object-level question, answered identically + (backward compatible). + +Response side (row-level attribution, present only for record-grained requests): +- New `ExplainMatchedRule` — a concrete share / sharing rule / ownership fact / + team / territory / RLS policy / Layer 0 tenant filter that admitted or + excluded the record at a layer, with its access level (`grants`), how it + reached the principal (`via`), the row predicate (`predicate`), and its + `effect` on the record. +- New `ExplainRecordAttribution` — a layer's per-record determination + (`outcome`, effective `rowFilter`, `matchesRecord`, matched `rules`), attached + as the optional `ExplainLayer.record`. +- New top-level `ExplainDecision.record` — the row-level bottom line + (`recordId`, `visible`, `decidedBy`). + +Reserved for the ADR-0095 kernel chain (β fills these; optional, backward +compatible): +- New `tenant_isolation` layer id (Layer 0, the always-first tenant wall). +- New `ExplainLayer.kernelTier` (`layer_0_tenant` | `layer_1_business`) so a + consumer can tell the tenant wall from business RLS without hard-coding ids. +- New `AuthzPosture` enum (`PLATFORM_ADMIN` > `TENANT_ADMIN` > `MEMBER` > + `EXTERNAL`) exposed as the optional `ExplainDecision.principal.posture`. + +Backward compatibility: every new field is optional or additive; existing +object-level requests and reports parse unchanged. The contract test locks the +new field shapes alongside the existing ones. diff --git a/packages/spec/api-surface.json b/packages/spec/api-surface.json index 4210b695c4..d03f281b6f 100644 --- a/packages/spec/api-surface.json +++ b/packages/spec/api-surface.json @@ -3913,14 +3913,20 @@ "AdminScope (type)", "AdminScopeInput (type)", "AdminScopeSchema (const)", + "AuthzPosture (type)", + "AuthzPostureSchema (const)", "CriteriaSharingRule (type)", "CriteriaSharingRuleSchema (const)", "ExplainDecision (type)", "ExplainDecisionSchema (const)", "ExplainLayer (type)", "ExplainLayerSchema (const)", + "ExplainMatchedRule (type)", + "ExplainMatchedRuleSchema (const)", "ExplainOperation (type)", "ExplainOperationSchema (const)", + "ExplainRecordAttribution (type)", + "ExplainRecordAttributionSchema (const)", "ExplainRequest (type)", "ExplainRequestInput (type)", "ExplainRequestSchema (const)", diff --git a/packages/spec/src/security/explain.test.ts b/packages/spec/src/security/explain.test.ts index 6c41eebc0c..b22a2276c1 100644 --- a/packages/spec/src/security/explain.test.ts +++ b/packages/spec/src/security/explain.test.ts @@ -23,6 +23,9 @@ import { ExplainDecisionSchema, AccessMatrixEntrySchema, AccessMatrixSchema, + AuthzPostureSchema, + ExplainMatchedRuleSchema, + ExplainRecordAttributionSchema, } from './explain.zod'; describe('ExplainOperationSchema — the operation vocabulary is fixed', () => { @@ -36,15 +39,18 @@ describe('ExplainOperationSchema — the operation vocabulary is fixed', () => { }); }); -describe('ExplainLayerSchema — the nine-layer pipeline + contributor shape', () => { +describe('ExplainLayerSchema — the ten-layer pipeline + contributor shape', () => { const LAYERS = [ + // [ADR-0095 D1] Layer 0 — the always-first tenant wall, ahead of principal. + 'tenant_isolation', 'principal', 'required_permissions', 'object_crud', 'fls', 'owd_baseline', 'depth', 'sharing', 'vama_bypass', 'rls', ]; - it('locks the nine layer ids, in order', () => { + it('locks the ten layer ids, including the ADR-0095 tenant_isolation Layer 0', () => { for (const layer of LAYERS) { expect(ExplainLayerSchema.parse({ layer, verdict: 'neutral', detail: 'x' }).layer).toBe(layer); } + // A near-miss ('tenant') is still rejected — only the full id is a member. expect(() => ExplainLayerSchema.parse({ layer: 'tenant', verdict: 'neutral', detail: 'x' })).toThrow(); }); @@ -86,6 +92,76 @@ describe('ExplainLayerSchema — the nine-layer pipeline + contributor shape', ( contributors: [{ kind: 'position', name: 'x', state: 'suspended' }], })).toThrow(); }); + + it('[C2] kernelTier + record are optional — object-level layers omit them', () => { + const bare = ExplainLayerSchema.parse({ layer: 'sharing', verdict: 'not_applicable', detail: 'x' }); + expect(bare.kernelTier).toBeUndefined(); + expect(bare.record).toBeUndefined(); + }); + + it('[ADR-0095 D1] kernelTier tags Layer 0 vs Layer 1; rejects other values', () => { + expect(ExplainLayerSchema.parse({ + layer: 'tenant_isolation', verdict: 'narrows', detail: 'x', kernelTier: 'layer_0_tenant', + }).kernelTier).toBe('layer_0_tenant'); + expect(ExplainLayerSchema.parse({ + layer: 'rls', verdict: 'narrows', detail: 'x', kernelTier: 'layer_1_business', + }).kernelTier).toBe('layer_1_business'); + expect(() => ExplainLayerSchema.parse({ + layer: 'rls', verdict: 'narrows', detail: 'x', kernelTier: 'layer_2', + })).toThrow(); + }); + + it('[C2] a record-grained layer carries per-record attribution', () => { + const layer = ExplainLayerSchema.parse({ + layer: 'sharing', verdict: 'widens', detail: 'x', kernelTier: 'layer_1_business', + record: { + outcome: 'admitted', + rowFilter: { owner: 'u2' }, + matchesRecord: true, + rules: [ + { kind: 'record_share', name: 'share_row_42', grants: 'read', via: 'group:sales_team', effect: 'admits' }, + { kind: 'ownership', name: 'owner-check', via: 'owner', effect: 'neutral' }, + ], + detail: 'admitted: an explicit share targets this row', + }, + }); + expect(layer.record?.outcome).toBe('admitted'); + expect(layer.record?.matchesRecord).toBe(true); + expect(layer.record?.rules.map((r) => r.kind)).toEqual(['record_share', 'ownership']); + expect(layer.record?.rules[0].grants).toBe('read'); + }); +}); + +describe('[ADR-0095 D2] AuthzPostureSchema — the monotonic posture ladder', () => { + it('locks the four rungs', () => { + for (const rung of ['PLATFORM_ADMIN', 'TENANT_ADMIN', 'MEMBER', 'EXTERNAL']) { + expect(AuthzPostureSchema.parse(rung)).toBe(rung); + } + expect(() => AuthzPostureSchema.parse('SUPERUSER')).toThrow(); + }); +}); + +describe('[C2] ExplainMatchedRule / ExplainRecordAttribution — row-level attribution', () => { + it('matched rule locks its kinds, effect, and optional grants/via/predicate', () => { + for (const kind of ['tenant_filter', 'owd_baseline', 'ownership', 'record_share', 'sharing_rule', 'team', 'territory', 'rls_policy']) { + expect(ExplainMatchedRuleSchema.parse({ kind, name: 'r', effect: 'admits' }).kind).toBe(kind); + } + expect(() => ExplainMatchedRuleSchema.parse({ kind: 'guess', name: 'r', effect: 'admits' })).toThrow(); + expect(() => ExplainMatchedRuleSchema.parse({ kind: 'ownership', name: 'r', effect: 'maybe' })).toThrow(); + const full = ExplainMatchedRuleSchema.parse({ + kind: 'sharing_rule', name: 'open_leads_to_sales', grants: 'edit', + via: 'criteria: status == open', predicate: { status: 'open' }, effect: 'admits', + }); + expect(full.grants).toBe('edit'); + expect(full.predicate).toEqual({ status: 'open' }); + }); + + it('record attribution defaults rules to [] and locks outcome', () => { + const bare = ExplainRecordAttributionSchema.parse({ outcome: 'excluded' }); + expect(bare.rules).toEqual([]); + expect(bare.matchesRecord).toBeUndefined(); + expect(() => ExplainRecordAttributionSchema.parse({ outcome: 'hidden' })).toThrow(); + }); }); describe('ExplainRequestSchema — the request contract', () => { @@ -96,6 +172,15 @@ describe('ExplainRequestSchema — the request contract', () => { expect(ExplainRequestSchema.parse({ object: 'x', operation: 'update', userId: 'u2' }).userId).toBe('u2'); expect(() => ExplainRequestSchema.parse({ operation: 'read' })).toThrow(); }); + + it('[C2] recordId is optional and round-trips; object-level requests stay backward-compatible', () => { + // Backward compat: the pre-C2 object-level request still parses, recordId absent. + const objectLevel = ExplainRequestSchema.parse({ object: 'leave_request', operation: 'read' }); + expect(objectLevel.recordId).toBeUndefined(); + // Record-grained request round-trips the recordId. + const recordLevel = ExplainRequestSchema.parse({ object: 'leave_request', operation: 'update', recordId: 'lr_42' }); + expect(recordLevel.recordId).toBe('lr_42'); + }); }); describe('ExplainDecisionSchema — the full decision report L3 consumes', () => { @@ -131,6 +216,68 @@ describe('ExplainDecisionSchema — the full decision report L3 consumes', () => expect(parsed.readFilter).toEqual({ owner: 'u2' }); }); + it('[C2] round-trips a record-grained decision with posture + per-record trace', () => { + const decision = { + allowed: false, + object: 'leave_request', + operation: 'update', + principal: { + userId: 'u2', + positions: ['approver', 'everyone'], + permissionSets: ['approve_set', 'member_default'], + principalKind: 'human', + posture: 'MEMBER', + }, + layers: [ + { + layer: 'tenant_isolation', verdict: 'narrows', detail: '…', kernelTier: 'layer_0_tenant', + record: { outcome: 'admitted', matchesRecord: true, rules: [{ kind: 'tenant_filter', name: 'org_wall', effect: 'admits' }] }, + }, + { + layer: 'sharing', verdict: 'not_applicable', detail: '…', kernelTier: 'layer_1_business', + record: { + outcome: 'excluded', + rowFilter: { owner: 'u2' }, + matchesRecord: false, + rules: [{ kind: 'ownership', name: 'owner-check', via: 'owner', effect: 'excludes' }], + detail: 'excluded: you are not the owner and no share targets this row', + }, + }, + ], + record: { recordId: 'lr_42', visible: false, decidedBy: 'sharing' }, + }; + const parsed = ExplainDecisionSchema.parse(decision); + expect(parsed.principal.posture).toBe('MEMBER'); + expect(parsed.record?.recordId).toBe('lr_42'); + expect(parsed.record?.visible).toBe(false); + expect(parsed.record?.decidedBy).toBe('sharing'); + expect(parsed.layers[0].kernelTier).toBe('layer_0_tenant'); + expect(parsed.layers[1].record?.outcome).toBe('excluded'); + }); + + it('[C2] object-level decisions omit record + posture (backward-compatible)', () => { + const parsed = ExplainDecisionSchema.parse({ + allowed: true, object: 'x', operation: 'read', + principal: { userId: 'u2' }, + layers: [{ layer: 'rls', verdict: 'narrows', detail: 'x', contributors: [] }], + readFilter: { owner: 'u2' }, + }); + expect(parsed.record).toBeUndefined(); + expect(parsed.principal.posture).toBeUndefined(); + }); + + it('[ADR-0095] rejects an unknown posture or decidedBy layer', () => { + expect(() => ExplainDecisionSchema.parse({ + allowed: true, object: 'x', operation: 'read', + principal: { userId: 'u', posture: 'GOD_MODE' }, layers: [], + })).toThrow(); + expect(() => ExplainDecisionSchema.parse({ + allowed: true, object: 'x', operation: 'read', + principal: { userId: 'u' }, layers: [], + record: { recordId: 'r1', visible: true, decidedBy: 'quantum' }, + })).toThrow(); + }); + it('principal.userId is nullable (anonymous), positions/permissionSets default to []', () => { const parsed = ExplainDecisionSchema.parse({ allowed: false, object: 'x', operation: 'read', diff --git a/packages/spec/src/security/explain.zod.ts b/packages/spec/src/security/explain.zod.ts index 1390f7b5f2..707e218465 100644 --- a/packages/spec/src/security/explain.zod.ts +++ b/packages/spec/src/security/explain.zod.ts @@ -15,8 +15,21 @@ import { lazySchema } from '../shared/lazy-schema'; * enforcement, minus the throw. * * Layer order mirrors the runtime pipeline: - * principal → required_permissions → object_crud → fls → owd_baseline → - * depth → sharing → vama_bypass → rls. + * 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. */ /** Operations the explain API accepts (the CRUD + lifecycle classes the evaluator maps). */ @@ -25,10 +38,107 @@ export const ExplainOperationSchema = z.enum([ ]); export type ExplainOperation = z.infer; +/** + * [ADR-0095 D2] The monotonic posture ladder resolved once in + * `resolveAuthzContext`. Each rung maps to exactly one row-visibility injection + * rule, and visibility is strictly nested down the ladder + * (PLATFORM_ADMIN ⊇ TENANT_ADMIN ⊇ MEMBER ⊇ EXTERNAL). Explain reports which + * rung the principal evaluated at so a consumer can answer "was this decided by + * the tenant-admin tier or ordinary business RLS?". `EXTERNAL` is defined and + * reservable now; its enforcement path ships when an external principal type + * exists (ADR-0095 D2). + */ +export const AuthzPostureSchema = z.enum([ + 'PLATFORM_ADMIN', + 'TENANT_ADMIN', + 'MEMBER', + 'EXTERNAL', +]).describe( + '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.', +); +export type AuthzPosture = z.infer; + +/** + * [C2 / ADR-0090 D6] A single concrete rule that governed a SPECIFIC record at + * one pipeline layer — the row-level analogue of {@link ExplainLayer}'s + * `contributors`. Populated only for record-grained requests; lets the sharing / + * rls / owd / tenant layers name the real share, sharing rule, ownership fact, + * team/territory grant, RLS policy, or Layer 0 tenant filter that admitted or + * excluded THAT row. The β engine fills these from the same sharing-service / + * RLS-compiler outputs enforcement uses. + */ +export const ExplainMatchedRuleSchema = lazySchema(() => z.object({ + /** What kind of row-visibility source this is at the layer. */ + kind: z.enum([ + 'tenant_filter', // [ADR-0095 D1] the Layer 0 org wall + 'owd_baseline', // the object's OWD default admitted/excluded the row + 'ownership', // the principal owns (or does not own) the record + 'record_share', // a concrete share row targeting this record + 'sharing_rule', // an owner/criteria sharing rule (SharingRuleType) + 'team', // team / account-team membership grant + 'territory', // territory assignment grant + 'rls_policy', // a business RLS policy predicate + ]).describe('The row-visibility source kind evaluated for this record at this layer.'), + /** Identifier of the concrete rule/share/policy (sharing-rule name, policy id, share row id). */ + name: z.string().describe('Stable identifier of the concrete rule, share, or policy that was evaluated.'), + /** + * For sharing sources: the access level this rule grants on the record + * (mirrors `SharingLevel` — read/edit/full). + */ + grants: z.enum(['read', 'edit', 'full']).optional() + .describe('Access level a sharing source grants on the record (mirrors SharingLevel).'), + /** How the rule reached the principal (e.g. `group:sales_team`, `position:approver`, `owner`, `criteria: status == open`). */ + via: z.string().optional() + .describe('How the rule reached the principal — recipient group/position, ownership, or the matching criteria.'), + /** The concrete row predicate this rule contributed, when filter-shaped (`null` = unrestricted). */ + predicate: z.unknown().optional() + .describe('The row predicate this rule contributed, when it is filter-shaped (null = unrestricted).'), + /** + * What this rule did to THIS record: `admits` (would make it + * visible/writable), `excludes` (filters it out), `neutral` (evaluated, + * no effect on the record). + */ + effect: z.enum(['admits', 'excludes', 'neutral']) + .describe('The rule\'s effect on THIS record: admits, excludes, or neutral.'), +})); +export type ExplainMatchedRule = z.infer; + +/** + * [C2 / ADR-0090 D6] A pipeline layer's determination for ONE specific record. + * Present on {@link ExplainLayer} only when the request carried a `recordId`. + * Carries the row-level story the object-level `verdict`/`detail` cannot: the + * effective row filter this layer composed, whether THIS record satisfies it, + * and the concrete rules that admitted or excluded it — the substrate for the + * β-phase "layer-by-layer expansion" UI (permission set → position → share → + * row rule → effective row filter) rendered per record. + */ +export const ExplainRecordAttributionSchema = lazySchema(() => z.object({ + /** Whether this layer admitted the record, excluded it, or did not evaluate it. */ + outcome: z.enum(['admitted', 'excluded', 'not_evaluated']) + .describe('This layer\'s row-level outcome for the record: admitted, excluded, or not_evaluated (skipped/not row-scoped).'), + /** The effective row predicate this layer contributed (`null` = unrestricted, `{ id: "__deny_all__" }` = zero rows). */ + rowFilter: z.unknown().optional() + .describe('The effective row predicate this layer contributed for the record set (null = unrestricted, __deny_all__ = zero rows).'), + /** Whether THIS record satisfies `rowFilter` — the row-level judgement behind `outcome`. */ + matchesRecord: z.boolean().optional() + .describe('Whether the specific record satisfies rowFilter — the judgement behind outcome.'), + /** Concrete rules/shares/policies this layer evaluated against the record, in evaluation order. */ + rules: z.array(ExplainMatchedRuleSchema).default([]) + .describe('Concrete rules, shares, or policies this layer evaluated against the record, in evaluation order.'), + /** Human-readable, record-specific explanation of the outcome. */ + detail: z.string().optional() + .describe('Human-readable, record-specific explanation of this layer\'s outcome.'), +})); +export type ExplainRecordAttribution = z.infer; + /** One evaluation-pipeline layer's contribution to the decision. */ export const ExplainLayerSchema = lazySchema(() => z.object({ /** Pipeline layer id, in evaluation order. */ layer: z.enum([ + // [ADR-0095 D1] Layer 0 — the always-first tenant wall, its own code path, + // AND-composed before any business RLS. Reserved for the β engine. + 'tenant_isolation', 'principal', 'required_permissions', 'object_crud', @@ -39,6 +149,15 @@ export const ExplainLayerSchema = lazySchema(() => z.object({ 'vama_bypass', 'rls', ]), + /** + * [ADR-0095 D1] Which kernel layer this pipeline step belongs to: the + * always-first tenant wall (`layer_0_tenant`) vs. business row-level security + * (`layer_1_business`). Lets a consumer answer "was this the tenant wall or a + * business RLS rule?" without hard-coding layer ids. Omitted on reports from + * engines that predate the kernel-chain split; the β engine sets it. + */ + kernelTier: z.enum(['layer_0_tenant', 'layer_1_business']).optional() + .describe('ADR-0095 kernel layer: layer_0_tenant = the always-first org wall; layer_1_business = business RLS/sharing/ownership.'), /** * What the layer did to the request: * `grants` (supplied the permission), `denies` (blocked it), `narrows` @@ -62,6 +181,15 @@ export const ExplainLayerSchema = lazySchema(() => z.object({ */ state: z.enum(['active', 'expired']).optional(), })).default([]), + /** + * [C2] Per-record attribution — present only when the request carried a + * `recordId`. The row-level analogue of `contributors`: what this layer did to + * the one concrete record (effective row filter, whether it matched, which + * shares/rules/policies admitted or excluded it). Absent on object-level + * requests, so the existing shape is unchanged. + */ + record: ExplainRecordAttributionSchema.optional() + .describe('Row-level determination for the specific record under explanation; set only for record-grained requests.'), })); export type ExplainLayer = z.infer; @@ -70,6 +198,15 @@ export const ExplainRequestSchema = lazySchema(() => z.object({ /** Object (entity) name the access question is about. */ object: z.string(), operation: ExplainOperationSchema, + /** + * [C2 / ADR-0090 D6] Optional id of ONE concrete record to explain at row + * granularity ("why can user X do OP on record Y?"). When supplied, the + * sharing / rls / owd / tenant_isolation layers add per-record `record` + * attribution and the decision carries a top-level `record` verdict. Omitted = + * an object-level question (the pre-C2 contract), answered identically. + */ + recordId: z.string().optional() + .describe('Optional id of one concrete record to explain at row granularity; omitted = object-level (pre-C2) request.'), /** * User to explain FOR. Omitted = the calling principal. Explaining another * user requires the `manage_users` capability (or system context) — the @@ -99,6 +236,13 @@ export const ExplainDecisionSchema = lazySchema(() => z.object({ principalKind: z.enum(['human', 'agent', 'service', 'guest', 'system']).optional(), /** [ADR-0090 D10] Dual attribution: who the principal acts for. */ onBehalfOf: z.object({ userId: z.string() }).optional(), + /** + * [ADR-0095 D2] The posture rung the principal evaluated at, when the + * context carries it. Resolved once; each rung maps to one row-visibility + * rule, strictly nested down the ladder. Optional until the β engine + * resolves posture; absent = pre-ladder report. + */ + posture: AuthzPostureSchema.optional(), }), /** Per-layer breakdown, in pipeline order. */ layers: z.array(ExplainLayerSchema), @@ -108,6 +252,33 @@ export const ExplainDecisionSchema = lazySchema(() => z.object({ * `{ id: '__deny_all__' }` = zero rows). */ readFilter: z.unknown().optional(), + /** + * [C2 / ADR-0090 D6] Record-grained verdict — present only when the request + * carried a `recordId`. The row-level bottom line for the one concrete record: + * whether it is visible/actionable, and which pipeline layer was decisive + * (the layer whose `record.outcome` excluded it, or the last one to admit it). + * The per-layer `record` attributions above carry the full trace; this is the + * summary a UI pins next to the record. + */ + record: lazySchema(() => z.object({ + /** The record this verdict is about (echoes the request `recordId`). */ + recordId: z.string().describe('The concrete record id this verdict is about (echoes the request recordId).'), + /** Whether the operation is permitted on THIS specific record. */ + visible: z.boolean().describe('Whether the operation is permitted on this specific record after all layers.'), + /** The pipeline layer that decided the outcome (excluded the record, or last admitted it). */ + decidedBy: z.enum([ + 'tenant_isolation', + 'principal', + 'required_permissions', + 'object_crud', + 'fls', + 'owd_baseline', + 'depth', + 'sharing', + 'vama_bypass', + 'rls', + ]).optional().describe('The pipeline layer that decided the record-level outcome (excluded it, or last admitted it).'), + })).optional().describe('Row-level verdict for the specific record; set only for record-grained requests.'), })); export type ExplainDecision = z.infer;