Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions .changeset/explain-zero-rows-sentinels.md
Original file line number Diff line number Diff line change
@@ -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.
6 changes: 3 additions & 3 deletions content/docs/references/security/explain.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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. |

Expand Down Expand Up @@ -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. |
Expand Down Expand Up @@ -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. |
Expand Down
85 changes: 85 additions & 0 deletions packages/spec/src/security/explain-zero-rows-sentinels.pin.test.ts
Original file line number Diff line number Diff line change
@@ -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.<field>.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<string, { description?: string }> }).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);
});
});
31 changes: 25 additions & 6 deletions packages/spec/src/security/explain.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.'),
Expand Down Expand Up @@ -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:
Expand Down
Loading