diff --git a/.changeset/explain-zero-rows-sentinels.md b/.changeset/explain-zero-rows-sentinels.md new file mode 100644 index 0000000000..62e544749f --- /dev/null +++ b/.changeset/explain-zero-rows-sentinels.md @@ -0,0 +1,34 @@ +--- +"@objectstack/spec": patch +--- + +docs(spec): the published `explain` payload contract now names BOTH zero-rows sentinels + +`ExplainDecision.readFilter` and `ExplainRecordAttribution.rowFilter` are the +machine artifact behind the explain prose, and their published description +enumerated the zero-rows vocabulary as a closed two-item list: `null` = +unrestricted, `{ id: '__deny_all__' }` = zero rows. + +That enumeration had grown incomplete. A fail-closed RLS denial — the "no +active organization" path, which composes plugin-security's `RLS_DENY_FILTER` +and is guaranteed to return zero rows — is reported with verdict `denies` and +`allowed: false`, while the payload keeps reporting the predicate that was +ACTUALLY composed: an `id` equality against `__rls_deny__` plus a colon and a +UUID-shaped suffix. So a reader of the contract met a zero-rows shape the +contract did not name. + +Both fields now name both shapes, say that the RLS denial is published as +composed (and can therefore ride inside an `$and` composite on `readFilter`), +and say which fields are the DECISION — `allowed` and the `rls` layer's +`verdict` for `readFilter`; `outcome` / `matchesRecord` and the layer's +`verdict` for `rowFilter` — so a consumer that pattern-matches the payload +alone to detect "zero rows" is told it must match both. + +`readFilter` carried its enumeration in a JSDoc block only, which no generator +reads: its published description cell and its JSON Schema `description` were +both EMPTY. It now carries a `.describe()`, so the reference page and the +emitted JSON Schema publish the vocabulary instead of nothing. + +Text only. `readFilter` / `rowFilter` remain `z.unknown()`, no accepted value +changes, no emitted payload changes, and the two sentinels are not unified — +the sentinel vocabulary itself is a separate, deployment-facing decision. diff --git a/content/docs/references/security/explain.mdx b/content/docs/references/security/explain.mdx index 85d6d7bfb7..64acffc5c8 100644 --- a/content/docs/references/security/explain.mdx +++ b/content/docs/references/security/explain.mdx @@ -106,7 +106,7 @@ ADR-0095 D2 posture rung — PLATFORM_ADMIN crosses the tenant wall where object | **operation** | `Enum<'read' \| 'create' \| 'update' \| 'delete' \| 'transfer' \| 'restore' \| 'purge' \| 'export'>` | ✅ | | | **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' \| …>; kernelTier?: Enum<'layer_0_tenant' \| 'layer_1_business'>; verdict: Enum<'grants' \| 'denies' \| 'narrows' \| 'widens' \| 'neutral' \| 'not_applicable'>; detail: string; … }[]` | ✅ | | -| **readFilter** | `any` | optional | | +| **readFilter** | `any` | optional | 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. | | **record** | `{ recordId: string; visible: boolean; decidedBy?: Enum<'tenant_isolation' \| 'principal' \| 'required_permissions' \| 'object_crud' \| …> }` | 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' \| …> }[]` | optional | Per-record verdicts for a batch request — records[i] answers recordIds[i]; set only when the request carried recordIds. | @@ -169,7 +169,7 @@ ADR-0095 D2 posture rung — PLATFORM_ADMIN crosses the tenant wall where object | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **outcome** | `Enum<'admitted' \| 'excluded' \| 'not_evaluated'>` | ✅ | 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 (null = unrestricted, __deny_all__ = zero rows). | +| **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. | | **rules** | `{ kind: Enum<'tenant_filter' \| 'owd_baseline' \| 'ownership' \| 'record_share' \| 'sharing_rule' \| …>; name: string; grants?: Enum<'read' \| 'edit' \| 'full'>; via?: string; … }[]` | optional (default: `[]`) | Concrete rules, shares, or policies this layer evaluated against the record, in evaluation order. | | **detail** | `string` | optional | Human-readable, record-specific explanation of this layer's outcome. | @@ -216,7 +216,7 @@ ADR-0095 D2 posture rung — PLATFORM_ADMIN crosses the tenant wall where object | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **outcome** | `Enum<'admitted' \| 'excluded' \| 'not_evaluated'>` | ✅ | 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 (null = unrestricted, __deny_all__ = zero rows). | +| **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. | | **rules** | `{ kind: Enum<'tenant_filter' \| 'owd_baseline' \| 'ownership' \| 'record_share' \| 'sharing_rule' \| …>; name: string; grants?: Enum<'read' \| 'edit' \| 'full'>; via?: string; … }[]` | optional (default: `[]`) | Concrete rules, shares, or policies this layer evaluated against the record, in evaluation order. | | **detail** | `string` | optional | Human-readable, record-specific explanation of this layer's outcome. | diff --git a/packages/spec/src/security/explain-zero-rows-sentinels.pin.test.ts b/packages/spec/src/security/explain-zero-rows-sentinels.pin.test.ts new file mode 100644 index 0000000000..89e99851f2 --- /dev/null +++ b/packages/spec/src/security/explain-zero-rows-sentinels.pin.test.ts @@ -0,0 +1,85 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#13961] The zero-rows vocabulary published on `explain`'s two payload + * fields must name BOTH sentinels, and must say which field is the decision. + * + * Why this pin exists. `ExplainDecision.readFilter` and + * `ExplainRecordAttribution.rowFilter` are the machine artifact behind the + * explain prose, and their published description was a closed, two-item + * enumeration: "`null` = unrestricted, `{ id: '__deny_all__' }` = zero rows". + * Then the platform grew a SECOND zero-rows shape that reaches those fields — + * plugin-security's fail-closed RLS denial, an `id` equality against + * `__rls_deny__` plus a colon and a UUID-shaped suffix — published exactly as + * composed rather than rewritten to the deny-all spelling. The enumeration a + * reader would trust was then missing a member, on a diagnostic surface whose + * whole purpose is telling an operator the truth about a request. + * + * ⛔ Scope: **the claim shape, not the wording.** Rephrasing a sentence, + * reordering the clauses, or naming a third shape that later becomes + * reachable is free. Dropping either sentinel from either field's published + * description, or dropping the statement that the sibling verdict fields — + * not the payload — are the decision, is not. + * + * Read THROUGH the schema (`.shape..description`), never by grepping + * the source: the description is what generators publish — the reference page + * at `content/docs/references/security/explain.mdx` and the emitted JSON + * Schema both copy this exact string — so reading it off the schema object is + * reading the published artifact's own source, and a `.describe()` that got + * detached from the field (moved onto a wrapper, dropped in a refactor) shows + * up here as `undefined` instead of passing on a source line that still exists. + * + * The RLS sentinel's prefix is pinned as a LITERAL rather than imported from + * `@objectstack/plugin-security`: `packages/spec` carries no runtime + * dependency (Prime Directive #2), and a spec test that reached into another + * package's source would also be a cross-package test input. The producer-side + * half of the agreement — that the constant really is spelled this way — is + * owned by plugin-security's own suites. + */ + +import { describe, it, expect } from 'vitest'; + +import { ExplainDecisionSchema, ExplainRecordAttributionSchema } from './explain.zod'; + +/** The composed deny-all sentinel — the member the enumeration always had. */ +const DENY_ALL = '__deny_all__'; +/** The fail-closed RLS denial's marker prefix — the member it was missing. */ +const RLS_DENY = '__rls_deny__'; + +const descriptionOf = (schema: unknown, field: string): string => { + const shape = (schema as { shape: Record }).shape; + const description = shape[field]?.description; + expect( + description, + `${field} must carry a .describe() — a JSDoc-only field publishes an EMPTY description cell`, + ).toBeTypeOf('string'); + return description as string; +}; + +describe.each([ + { field: 'readFilter', schema: ExplainDecisionSchema, decidedBy: [/\ballowed\b/, /\bverdict\b/] }, + { + field: 'rowFilter', + schema: ExplainRecordAttributionSchema, + decidedBy: [/\boutcome\b/, /\bmatchesRecord\b/, /\bverdict\b/], + }, +])('$field — the published zero-rows vocabulary', ({ field, schema, decidedBy }) => { + it('names BOTH zero-rows sentinels', () => { + const description = descriptionOf(schema, field); + expect(description).toContain(DENY_ALL); + expect(description).toContain(RLS_DENY); + }); + + it('still names the unrestricted pole, so the enumeration stays complete at both ends', () => { + expect(descriptionOf(schema, field)).toContain('null'); + }); + + it('says the sibling verdict fields — not this payload — are the decision', () => { + const description = descriptionOf(schema, field); + for (const marker of decidedBy) expect(description).toMatch(marker); + }); + + it('tells a payload-matching consumer it must match BOTH shapes', () => { + expect(descriptionOf(schema, field)).toMatch(/match both/i); + }); +}); diff --git a/packages/spec/src/security/explain.zod.ts b/packages/spec/src/security/explain.zod.ts index be1a08bbb9..dcb2b4c387 100644 --- a/packages/spec/src/security/explain.zod.ts +++ b/packages/spec/src/security/explain.zod.ts @@ -137,9 +137,19 @@ 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). */ + /** + * The effective row predicate this layer contributed, published exactly as + * the layer composed it. TWO shapes mean zero rows: `{ id: '__deny_all__' }`, + * the composed deny-all sentinel, and plugin-security's fail-closed RLS + * denial — an `id` equality against `__rls_deny__`, a colon, and a + * UUID-shaped suffix no record can carry. Neither shape is the DECISION: + * this attribution's `outcome` / `matchesRecord` and the layer's `verdict` + * are, and they answer correctly for both. A consumer that pattern-matches + * this payload ALONE to detect "zero rows" must match both shapes. + * The open pole is unchanged: `null` = unrestricted. + */ rowFilter: z.unknown().optional() - .describe('The effective row predicate this layer contributed for the record set (null = unrestricted, __deny_all__ = zero rows).'), + .describe('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.'), /** 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.'), @@ -359,11 +369,20 @@ export const ExplainDecisionSchema = lazySchema(() => z.object({ /** Per-layer breakdown, in pipeline order. */ layers: z.array(ExplainLayerSchema), /** - * For `read`: the composed row filter the caller would be served with — - * the machine artifact behind the prose (`null` = unrestricted, - * `{ id: '__deny_all__' }` = zero rows). + * For `read` and `export` (#3544): the composed row filter the caller would + * be served with — the machine artifact behind the prose. TWO shapes mean + * zero rows: `{ id: '__deny_all__' }`, the composed deny-all sentinel, and + * plugin-security's fail-closed RLS denial — an `id` equality against + * `__rls_deny__`, a colon, and a UUID-shaped suffix no record can carry. + * Only the first is rewritten to the deny-all spelling when it is composed + * in; the RLS denial is published as composed, so it can also ride inside an + * `$and` composite. Neither shape is the DECISION: `allowed` and the `rls` + * layer's `verdict` are, and they answer correctly for both. A consumer that + * pattern-matches this payload ALONE to detect "zero rows" must match both. + * The open pole is unchanged: `null` = unrestricted. */ - readFilter: z.unknown().optional(), + readFilter: z.unknown().optional() + .describe('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.'), /** * [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: