diff --git a/.changeset/quick-hoops-drum.md b/.changeset/quick-hoops-drum.md new file mode 100644 index 0000000000..e38d46fbca --- /dev/null +++ b/.changeset/quick-hoops-drum.md @@ -0,0 +1,6 @@ +--- +"@objectstack/spec": minor +"@objectstack/rest": minor +--- + +Add a batch form to `security/explain` (#8326): `recordIds: string[]` (max 200, mutually exclusive with `recordId`) on the existing request shape answers the per-record `decision.record.visible` verdict map for one `(object, operation)` pair in one round trip. The response gains an optional `records` array where `records[i]` answers `recordIds[i]` (duplicates answered per position); a missing record fail-closes to `visible: false` with `decidedBy` omitted. Each id is evaluated through the singular pipeline, so the batch answer for a record is identical to the singular answer by construction. Singular and object-level requests and responses stay byte-compatible; a request carrying both spellings, an empty batch, or more than 200 ids is refused with `400 VALIDATION_FAILED`. diff --git a/content/docs/references/security/explain.mdx b/content/docs/references/security/explain.mdx index 9d1b548d2e..7eec00f317 100644 --- a/content/docs/references/security/explain.mdx +++ b/content/docs/references/security/explain.mdx @@ -107,7 +107,8 @@ ADR-0095 D2 posture rung — PLATFORM_ADMIN crosses the tenant wall where object | **principal** | `{ userId: string \| null; positions: string[]; permissionSets: string[]; principalKind?: Enum<'human' \| 'agent' \| 'service' \| 'guest' \| 'system'>; … }` | ✅ | | | **layers** | `{ layer: Enum<'tenant_isolation' \| 'principal' \| 'required_permissions' \| 'object_crud' \| … +6 more>; kernelTier?: Enum<'layer_0_tenant' \| 'layer_1_business'>; verdict: Enum<'grants' \| 'denies' \| 'narrows' \| 'widens' \| 'neutral' \| 'not_applicable'>; detail: string; … }[]` | ✅ | | | **readFilter** | `any` | optional | | -| **record** | `{ recordId: string; visible: boolean; decidedBy?: Enum<'tenant_isolation' \| 'principal' \| 'required_permissions' \| 'object_crud' \| … +6 more> }` | optional | Row-level verdict for the specific record; set only for record-grained requests. | +| **record** | `{ recordId: string; visible: boolean; decidedBy?: Enum<'tenant_isolation' \| 'principal' \| 'required_permissions' \| 'object_crud' \| … +6 more> }` | optional | Row-level verdict for the specific record; set only for singular record-grained requests. | +| **records** | `{ recordId: string; visible: boolean; decidedBy?: Enum<'tenant_isolation' \| 'principal' \| 'required_permissions' \| 'object_crud' \| … +6 more> }[]` | optional | Per-record verdicts for a batch request — records[i] answers recordIds[i]; set only when the request carried recordIds. | --- @@ -183,7 +184,8 @@ ADR-0095 D2 posture rung — PLATFORM_ADMIN crosses the tenant wall where object | :--- | :--- | :--- | :--- | | **object** | `string` | ✅ | | | **operation** | `Enum<'read' \| 'create' \| 'update' \| 'delete' \| 'transfer' \| 'restore' \| 'purge' \| 'export'>` | ✅ | | -| **recordId** | `string` | optional | Optional id of one concrete record to explain at row granularity; omitted = object-level (pre-C2) request. | +| **recordId** | `string` | optional | Optional id of one concrete record to explain at row granularity; omitted = object-level (pre-C2) request. Mutually exclusive with recordIds. | +| **recordIds** | `string[]` | optional | Batch of record ids (1–200) to answer at row granularity in one round trip; records[i] answers recordIds[i]. Mutually exclusive with recordId. | | **userId** | `string` | optional | | diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index 4d2e7b54bd..35b6f5cfff 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -9045,7 +9045,14 @@ export class RestServer { * explain engine (framework#2696). * * GET {basePath}/security/explain?object=…&operation=…&userId=… - * POST {basePath}/security/explain body: { object, operation, userId? } + * POST {basePath}/security/explain body: { object, operation, userId?, + * recordId? | recordIds? } + * + * [#8326] `recordIds` (max 200, exclusive with `recordId`) is the batch + * form of the record-grained question: one `(object, operation)` pair + * answered per record in one round trip — `records[i]` answers + * `recordIds[i]`; each entry is the verdict the singular form returns for + * that id (see `ExplainRequestSchema`'s TSDoc for the full contract). * * Delegates to the security service's `explain(request, callerContext)` * (`SecurityPlugin.explainAccessForCaller`) — the same code paths the @@ -9150,6 +9157,14 @@ export class RestServer { // refusal gate is imitating, so there is nothing to add here. const src = req.method === 'GET' ? (req.query ?? {}) : (req.body ?? {}); const { ExplainRequestSchema } = await import('@objectstack/spec/security'); + // [#8326] GET-transport normalization ONLY: a query string + // cannot spell a one-element array (`?recordIds=a` parses to + // the bare string), so a lone string is wrapped on GET. On + // POST the body is JSON and can say what it means — a string + // where the contract says array stays a 400, not a wrap. + const rawRecordIds = req.method === 'GET' && typeof src.recordIds === 'string' && src.recordIds !== '' + ? [src.recordIds] + : src.recordIds; const parsed = (ExplainRequestSchema as any).safeParse({ object: src.object, operation: src.operation ?? 'read', @@ -9157,17 +9172,45 @@ export class RestServer { // [C2 / ADR-0095] Optional record id — explains ONE concrete // row at record granularity; omitted stays object-level. ...(src.recordId != null && src.recordId !== '' ? { recordId: src.recordId } : {}), + // [#8326] Optional batch of record ids — the schema owns the + // cap (200), the min (1), and recordId/recordIds mutual + // exclusion, so every refusal is the one 400 below. + ...(rawRecordIds != null ? { recordIds: rawRecordIds } : {}), }); if (!parsed.success) { return respondError( res, 400, 'VALIDATION_FAILED', - 'Invalid explain request — expected { object: string, operation: read|create|update|delete|transfer|restore|purge, userId?: string, recordId?: string }.', + 'Invalid explain request — expected { object: string, operation: read|create|update|delete|transfer|restore|purge, userId?: string, recordId?: string, recordIds?: string[] (max 200, exclusive with recordId) }.', String(parsed.error?.message ?? '').slice(0, 1000), ); } - const decision = await svc.explain(parsed.data, context); - res.json(decision); + const { recordIds, ...singularRequest } = parsed.data as { recordIds?: string[] } & Record; + if (!recordIds) { + // Singular / object-level — the pre-#8326 path, byte-identical. + const decision = await svc.explain(parsed.data, context); + return res.json(decision); + } + + // [#8326] Batch form — transport amortization of the SINGULAR + // evaluation, not a new semantic: the object-level trace is one + // object-level explain, and each per-record verdict is the + // singular record-grained explain for that id, relayed + // verbatim. "Batch answer ≡ N singular answers" is therefore a + // property of the construction, and the agreement test pins it + // from staying that way by accident. + const decision = await svc.explain(singularRequest, context); + const verdictById = new Map(); + for (const id of new Set(recordIds)) { + const single = await svc.explain({ ...singularRequest, recordId: id }, context); + // A service without record-grained support answers no + // record verdict; fail CLOSED (a hidden button beats a + // shown-then-403), with no decidedBy fabricated. + verdictById.set(id, single?.record ?? { recordId: id, visible: false }); + } + // Ordering contract: records[i] answers recordIds[i] — same + // order, same length, duplicates answered per position. + res.json({ ...decision, records: recordIds.map((id) => verdictById.get(id)) }); } catch (error: any) { const msg = String(error?.message ?? error ?? ''); if ( diff --git a/packages/rest/src/security-routes.test.ts b/packages/rest/src/security-routes.test.ts index 7f1f449518..73bed1d4c9 100644 --- a/packages/rest/src/security-routes.test.ts +++ b/packages/rest/src/security-routes.test.ts @@ -153,3 +153,147 @@ describe('GET/POST /security/explain (ADR-0090 D6)', () => { expect(res.body.error.message).toBe('boom'); }); }); + +// ── [#8326] batch form — recordIds on the same request shape ───────────────── + +/** + * A deterministic fake security service: the record verdict is a pure function + * of the recordId, so "batch answer ≡ N singular answers" is checkable as data + * rather than as mock-call bookkeeping. `r_gone` plays the missing record — + * the engine's fail-closed answer is `visible: false` with no `decidedBy`. + */ +function deterministicExplain() { + return vi.fn(async (request: any) => { + const base = { ...DECISION, object: request.object, operation: request.operation }; + if (!request.recordId) return base; + if (request.recordId === 'r_gone') { + return { ...base, record: { recordId: request.recordId, visible: false } }; + } + const visible = request.recordId.endsWith('_ok'); + return { + ...base, + record: { recordId: request.recordId, visible, decidedBy: visible ? 'sharing' : 'rls' }, + }; + }); +} + +describe('[#8326] POST/GET /security/explain with recordIds (batch form)', () => { + it('answers records[i] for recordIds[i] — same order, same length, duplicates per position', async () => { + const explain = deterministicExplain(); + const { post } = buildServer(async () => ({ explain }), { callerCtx: CALLER }); + const res = mockRes(); + const recordIds = ['a_ok', 'b_no', 'r_gone', 'a_ok']; + await post!.handler({ method: 'POST', params: {}, headers: {}, body: { object: 'task', operation: 'update', recordIds } } as any, res); + + expect(res.statusCode).toBe(200); + expect(res.body.records).toEqual([ + { recordId: 'a_ok', visible: true, decidedBy: 'sharing' }, + { recordId: 'b_no', visible: false, decidedBy: 'rls' }, + { recordId: 'r_gone', visible: false }, // missing record: fail closed, no decider + { recordId: 'a_ok', visible: true, decidedBy: 'sharing' }, + ]); + // The object-level trace rides along, without a singular `record` verdict. + expect(res.body.allowed).toBe(true); + expect(res.body.record).toBeUndefined(); + // Evaluation is the singular pipeline per unique id + one object-level + // pass — the service never sees `recordIds`. + for (const call of explain.mock.calls) expect(call[0].recordIds).toBeUndefined(); + expect(explain.mock.calls.map((c: any[]) => c[0].recordId).sort((a: any, b: any) => String(a).localeCompare(String(b)))) + .toEqual([undefined, 'a_ok', 'b_no', 'r_gone'].sort((a: any, b: any) => String(a).localeCompare(String(b)))); + }); + + it('AGREEMENT: the batch answer equals N singular answers for the same records', async () => { + const recordIds = ['a_ok', 'b_no', 'r_gone', 'c_ok']; + + // N singular round trips through the REAL handler. + const singularVerdicts: unknown[] = []; + for (const recordId of recordIds) { + const { post } = buildServer(async () => ({ explain: deterministicExplain() }), { callerCtx: CALLER }); + const res = mockRes(); + await post!.handler({ method: 'POST', params: {}, headers: {}, body: { object: 'task', operation: 'update', recordId } } as any, res); + expect(res.statusCode).toBe(200); + singularVerdicts.push(res.body.record); + } + + // One batch round trip through the REAL handler, same service semantics. + const { post } = buildServer(async () => ({ explain: deterministicExplain() }), { callerCtx: CALLER }); + const res = mockRes(); + await post!.handler({ method: 'POST', params: {}, headers: {}, body: { object: 'task', operation: 'update', recordIds } } as any, res); + expect(res.statusCode).toBe(200); + + expect(res.body.records).toEqual(singularVerdicts); + }); + + it('refuses a batch over the 200-id cap with 400 VALIDATION_FAILED (never truncates)', async () => { + const explain = deterministicExplain(); + const { post } = buildServer(async () => ({ explain }), { callerCtx: CALLER }); + const res = mockRes(); + const recordIds = Array.from({ length: 201 }, (_, i) => `r_${i}_ok`); + await post!.handler({ method: 'POST', params: {}, headers: {}, body: { object: 'task', operation: 'update', recordIds } } as any, res); + + // The refusal envelope: code AND status (ADR-0112 D5 position). + expect(res.statusCode).toBe(400); + expect(res.body.error.code).toBe('VALIDATION_FAILED'); + expect(explain).not.toHaveBeenCalled(); + }); + + it('refuses recordId + recordIds together with 400 (loud, no silent precedence) and an empty batch too', async () => { + const explain = deterministicExplain(); + const { post } = buildServer(async () => ({ explain }), { callerCtx: CALLER }); + + let res = mockRes(); + await post!.handler({ method: 'POST', params: {}, headers: {}, body: { object: 'task', operation: 'update', recordId: 'a_ok', recordIds: ['a_ok'] } } as any, res); + expect(res.statusCode).toBe(400); + expect(res.body.error.code).toBe('VALIDATION_FAILED'); + + res = mockRes(); + await post!.handler({ method: 'POST', params: {}, headers: {}, body: { object: 'task', operation: 'update', recordIds: [] } } as any, res); + expect(res.statusCode).toBe(400); + expect(res.body.error.code).toBe('VALIDATION_FAILED'); + expect(explain).not.toHaveBeenCalled(); + }); + + it('POST does NOT wrap a bare-string recordIds — the JSON body can spell an array, so a string is a 400', async () => { + const explain = deterministicExplain(); + const { post } = buildServer(async () => ({ explain }), { callerCtx: CALLER }); + const res = mockRes(); + await post!.handler({ method: 'POST', params: {}, headers: {}, body: { object: 'task', operation: 'update', recordIds: 'a_ok' } } as any, res); + expect(res.statusCode).toBe(400); + expect(res.body.error.code).toBe('VALIDATION_FAILED'); + expect(explain).not.toHaveBeenCalled(); + }); + + it('GET wraps a single repeated query param (a query string cannot spell a one-element array)', async () => { + const explain = deterministicExplain(); + const { get } = buildServer(async () => ({ explain }), { callerCtx: CALLER }); + const res = mockRes(); + await get!.handler({ method: 'GET', params: {}, headers: {}, query: { object: 'task', operation: 'update', recordIds: 'a_ok' } } as any, res); + expect(res.statusCode).toBe(200); + expect(res.body.records).toEqual([{ recordId: 'a_ok', visible: true, decidedBy: 'sharing' }]); + }); + + it('a singular request stays byte-compatible: no records[] key appears on the response', async () => { + const explain = deterministicExplain(); + const { post } = buildServer(async () => ({ explain }), { callerCtx: CALLER }); + const res = mockRes(); + await post!.handler({ method: 'POST', params: {}, headers: {}, body: { object: 'task', operation: 'update', recordId: 'a_ok' } } as any, res); + expect(res.statusCode).toBe(200); + expect(res.body.record).toEqual({ recordId: 'a_ok', visible: true, decidedBy: 'sharing' }); + expect('records' in res.body).toBe(false); + expect(explain).toHaveBeenCalledTimes(1); + expect(explain).toHaveBeenCalledWith({ object: 'task', operation: 'update', recordId: 'a_ok' }, CALLER); + }); + + it('fail-closes per record when the service predates record-grained explain (no record verdict answered)', async () => { + // A pre-C2 service ignores recordId and returns object-level decisions only. + const explain = vi.fn().mockResolvedValue(DECISION); + const { post } = buildServer(async () => ({ explain }), { callerCtx: CALLER }); + const res = mockRes(); + await post!.handler({ method: 'POST', params: {}, headers: {}, body: { object: 'task', operation: 'update', recordIds: ['a', 'b'] } } as any, res); + expect(res.statusCode).toBe(200); + expect(res.body.records).toEqual([ + { recordId: 'a', visible: false }, + { recordId: 'b', visible: false }, + ]); + }); +}); diff --git a/packages/spec/api-surface/security.json b/packages/spec/api-surface/security.json index 0c24839148..fec216823c 100644 --- a/packages/spec/api-surface/security.json +++ b/packages/spec/api-surface/security.json @@ -18,6 +18,7 @@ "CriteriaSharingRule (type)", "CriteriaSharingRuleParsed (type)", "CriteriaSharingRuleSchema (const)", + "EXPLAIN_BATCH_MAX_RECORD_IDS (const)", "EffectiveObjectPermission (type)", "EffectiveObjectPermissionSchema (const)", "ExplainDecision (type)", diff --git a/packages/spec/authorable-surface/security.json b/packages/spec/authorable-surface/security.json index d7c462d225..407767cb13 100644 --- a/packages/spec/authorable-surface/security.json +++ b/packages/spec/authorable-surface/security.json @@ -69,6 +69,7 @@ "security/ExplainDecision:principal", "security/ExplainDecision:readFilter", "security/ExplainDecision:record", + "security/ExplainDecision:records", "security/ExplainLayer:contributors", "security/ExplainLayer:detail", "security/ExplainLayer:kernelTier", @@ -89,6 +90,7 @@ "security/ExplainRequest:object", "security/ExplainRequest:operation", "security/ExplainRequest:recordId", + "security/ExplainRequest:recordIds", "security/ExplainRequest:userId", "security/FieldPermission:editable", "security/FieldPermission:readable", diff --git a/packages/spec/export-origins/security.json b/packages/spec/export-origins/security.json index 08e20b88ac..15c542c16d 100644 --- a/packages/spec/export-origins/security.json +++ b/packages/spec/export-origins/security.json @@ -18,6 +18,7 @@ "CriteriaSharingRule": "src/security/sharing.zod.ts#CriteriaSharingRule (type)", "CriteriaSharingRuleParsed": "src/security/sharing.zod.ts#CriteriaSharingRuleParsed (type)", "CriteriaSharingRuleSchema": "src/security/sharing.zod.ts#CriteriaSharingRuleSchema (const)", + "EXPLAIN_BATCH_MAX_RECORD_IDS": "src/security/explain.zod.ts#EXPLAIN_BATCH_MAX_RECORD_IDS (const)", "EffectiveObjectPermission": "src/security/permission.zod.ts#EffectiveObjectPermission (type)", "EffectiveObjectPermissionSchema": "src/security/permission.zod.ts#EffectiveObjectPermissionSchema (const)", "ExplainDecision": "src/security/explain.zod.ts#ExplainDecision (type)", diff --git a/packages/spec/src/security/explain.test.ts b/packages/spec/src/security/explain.test.ts index b22a2276c1..96e5430406 100644 --- a/packages/spec/src/security/explain.test.ts +++ b/packages/spec/src/security/explain.test.ts @@ -26,6 +26,7 @@ import { AuthzPostureSchema, ExplainMatchedRuleSchema, ExplainRecordAttributionSchema, + EXPLAIN_BATCH_MAX_RECORD_IDS, } from './explain.zod'; describe('ExplainOperationSchema — the operation vocabulary is fixed', () => { @@ -181,6 +182,39 @@ describe('ExplainRequestSchema — the request contract', () => { const recordLevel = ExplainRequestSchema.parse({ object: 'leave_request', operation: 'update', recordId: 'lr_42' }); expect(recordLevel.recordId).toBe('lr_42'); }); + + it('[#8326] recordIds round-trips a batch; singular and object-level requests are untouched by its presence in the schema', () => { + const batch = ExplainRequestSchema.parse({ object: 'leave_request', operation: 'update', recordIds: ['lr_1', 'lr_2'] }); + expect(batch.recordIds).toEqual(['lr_1', 'lr_2']); + expect(batch.recordId).toBeUndefined(); + // The singular spelling parses to an identical shape with recordIds absent. + const singular = ExplainRequestSchema.parse({ object: 'leave_request', operation: 'update', recordId: 'lr_1' }); + expect(singular.recordIds).toBeUndefined(); + }); + + it('[#8326] the cap is 200: exactly 200 ids parse, 201 are refused (never truncated)', () => { + const ids = (n: number) => Array.from({ length: n }, (_, i) => `r_${i}`); + expect(ExplainRequestSchema.parse({ object: 'x', operation: 'read', recordIds: ids(200) }).recordIds).toHaveLength(200); + expect(EXPLAIN_BATCH_MAX_RECORD_IDS).toBe(200); + const over = ExplainRequestSchema.safeParse({ object: 'x', operation: 'read', recordIds: ids(201) }); + expect(over.success).toBe(false); + }); + + it('[#8326] an empty recordIds array is refused — send at least one id or omit the field', () => { + expect(ExplainRequestSchema.safeParse({ object: 'x', operation: 'read', recordIds: [] }).success).toBe(false); + }); + + it('[#8326] recordId + recordIds together is a loud refusal, never a silent precedence', () => { + const both = ExplainRequestSchema.safeParse({ + object: 'x', operation: 'read', recordId: 'r_1', recordIds: ['r_1', 'r_2'], + }); + expect(both.success).toBe(false); + expect(JSON.stringify(both.success ? [] : both.error.issues)).toContain('mutually exclusive'); + }); + + it('[#8326] non-string members are refused by the element schema', () => { + expect(ExplainRequestSchema.safeParse({ object: 'x', operation: 'read', recordIds: [42] }).success).toBe(false); + }); }); describe('ExplainDecisionSchema — the full decision report L3 consumes', () => { @@ -255,6 +289,29 @@ describe('ExplainDecisionSchema — the full decision report L3 consumes', () => expect(parsed.layers[1].record?.outcome).toBe('excluded'); }); + it('[#8326] round-trips a batch decision — records[] carries the same verdict shape as record', () => { + const parsed = ExplainDecisionSchema.parse({ + allowed: true, object: 'leave_request', operation: 'update', + principal: { userId: 'u2' }, + layers: [{ layer: 'object_crud', verdict: 'grants', detail: 'x', contributors: [] }], + records: [ + { recordId: 'lr_1', visible: true, decidedBy: 'sharing' }, + { recordId: 'lr_2', visible: false, decidedBy: 'rls' }, + // Missing record: fail-closed verdict with decidedBy omitted. + { recordId: 'lr_gone', visible: false }, + ], + }); + expect(parsed.records).toHaveLength(3); + expect(parsed.records?.[0]).toEqual({ recordId: 'lr_1', visible: true, decidedBy: 'sharing' }); + expect(parsed.records?.[2]).toEqual({ recordId: 'lr_gone', visible: false }); + // An unknown decidedBy is refused in records[] exactly as in record. + expect(() => ExplainDecisionSchema.parse({ + allowed: true, object: 'x', operation: 'read', + principal: { userId: 'u' }, layers: [], + records: [{ recordId: 'r1', visible: true, decidedBy: 'quantum' }], + })).toThrow(); + }); + it('[C2] object-level decisions omit record + posture (backward-compatible)', () => { const parsed = ExplainDecisionSchema.parse({ allowed: true, object: 'x', operation: 'read', @@ -263,6 +320,9 @@ describe('ExplainDecisionSchema — the full decision report L3 consumes', () => readFilter: { owner: 'u2' }, }); expect(parsed.record).toBeUndefined(); + // [#8326] The batch array is absent too — singular and object-level + // responses are byte-identical to the pre-batch contract. + expect(parsed.records).toBeUndefined(); expect(parsed.principal.posture).toBeUndefined(); }); diff --git a/packages/spec/src/security/explain.zod.ts b/packages/spec/src/security/explain.zod.ts index 018c29c186..2f50757c46 100644 --- a/packages/spec/src/security/explain.zod.ts +++ b/packages/spec/src/security/explain.zod.ts @@ -217,6 +217,15 @@ export type ExplainLayer = z.input; /** Post-parse shape of {@link ExplainLayer} — defaults applied, transforms run (ADR-0122). */ export type ExplainLayerParsed = z.infer; +/** + * [#8326] Hard cap on `recordIds` per batch explain request. The batch form is + * a transport amortization of the singular evaluation (a 50-row list page was + * measured at 2 probes per row = 100 POSTs without it), not a bulk-scan API — + * the cap keeps one request's evaluation cost bounded, and a consumer with + * more records paginates under it. + */ +export const EXPLAIN_BATCH_MAX_RECORD_IDS = 200; + /** Request shape for the explain API. */ export const ExplainRequestSchema = lazySchema(() => z.object({ /** Object (entity) name the access question is about. */ @@ -228,9 +237,36 @@ export const ExplainRequestSchema = lazySchema(() => z.object({ * 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. + * Mutually exclusive with `recordIds`. */ recordId: z.string().optional() - .describe('Optional id of one concrete record to explain at row granularity; omitted = object-level (pre-C2) request.'), + .describe('Optional id of one concrete record to explain at row granularity; omitted = object-level (pre-C2) request. Mutually exclusive with recordIds.'), + /** + * [#8326] Batch form of the record-grained question: the SAME evaluation as + * `recordId`, amortized over one round trip for one `(object, operation)` + * pair. Each id is evaluated through the singular pipeline (same layering + * semantics — the batch answer for a record is defined as identical to the + * singular answer for that record), and the decision carries a top-level + * `records` array of per-record verdicts. + * + * Contract details: + * - **Ordering**: `decision.records[i]` answers `recordIds[i]` — same order, + * same length, duplicates answered per position. + * - **Cap**: at most {@link EXPLAIN_BATCH_MAX_RECORD_IDS} (200) ids; more is + * refused at validation (HTTP 400), never silently truncated. Empty arrays + * are refused too — send at least one id or omit the field. + * - **Missing records**: an id that does not resolve to a readable row under + * a system read (filtered, deleted, or never existed) gets + * `{ recordId, visible: false }` with `decidedBy` omitted — the same + * fail-closed answer the singular form gives for that id. + * - **Mutually exclusive** with `recordId`: a request carrying both is + * refused loudly rather than silently preferring either spelling. + * - Batch responses carry the per-layer trace at OBJECT level only (no + * per-layer `record` attribution); ask the singular form for one record's + * full row-level story. + */ + recordIds: z.array(z.string()).min(1).max(EXPLAIN_BATCH_MAX_RECORD_IDS).optional() + .describe('Batch of record ids (1–200) to answer at row granularity in one round trip; records[i] answers recordIds[i]. Mutually exclusive with recordId.'), /** * User to explain FOR. Omitted = the calling principal. Explaining another * user requires the `manage_users` capability (or system context) — the @@ -239,9 +275,52 @@ export const ExplainRequestSchema = lazySchema(() => z.object({ * as the runtime resolver (everyone anchor, additive baseline). */ userId: z.string().optional(), +}).superRefine((req, ctx) => { + // [#8326] recordId and recordIds are one question in two spellings — a + // request carrying both is ambiguous, and an ambiguous authorization + // question must fail loudly, never resolve by silent precedence. + if (req.recordId !== undefined && req.recordIds !== undefined) { + ctx.addIssue({ + code: 'custom', + path: ['recordIds'], + message: 'recordId and recordIds are mutually exclusive — send one record as recordId, or the batch as recordIds, never both.', + }); + } })); export type ExplainRequest = z.input; +/** + * [C2 / #8326] The record-grained bottom line for ONE record — the shape of + * the singular `decision.record` verdict AND of each `decision.records[]` + * entry (the batch form reuses the schema so the two can never drift). + * Deliberately NOT exported: consumers hold it as + * `NonNullable`; the per-layer trace behind it + * lives in {@link ExplainRecordAttributionSchema}. + */ +const ExplainRecordVerdictSchema = lazySchema(() => z.object({ + /** The record this verdict is about (echoes the request `recordId` / `recordIds[i]`). */ + recordId: z.string().describe('The concrete record id this verdict is about (echoes the request recordId or recordIds[i]).'), + /** 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). Omitted when no layer answered — notably for a missing + * record, whose fail-closed verdict is `visible: false` with no decider. + */ + 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); omitted for a missing record.'), +})); + /** The full decision report. */ export const ExplainDecisionSchema = lazySchema(() => z.object({ /** The bottom line — would the middleware allow this operation? */ @@ -282,25 +361,19 @@ export const ExplainDecisionSchema = lazySchema(() => z.object({ * 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.'), + record: ExplainRecordVerdictSchema.optional() + .describe('Row-level verdict for the specific record; set only for singular record-grained requests.'), + /** + * [#8326] Batch record-grained verdicts — present only when the request + * carried `recordIds`. `records[i]` answers `recordIds[i]` (same order, same + * length, duplicates answered per position); each entry is the SAME verdict + * the singular form returns for that id (a missing record fail-closes to + * `visible: false` with `decidedBy` omitted). The `layers` of a batch + * response are the object-level trace; per-layer `record` attribution is the + * singular form's job. + */ + records: z.array(ExplainRecordVerdictSchema).optional() + .describe('Per-record verdicts for a batch request — records[i] answers recordIds[i]; set only when the request carried recordIds.'), })); export type ExplainDecision = z.input; /** Post-parse shape of {@link ExplainDecision} — defaults applied, transforms run (ADR-0122). */