Skip to content

Commit 7317cf2

Browse files
claude[bot]claude
andauthored
docs(spec): name both zero-rows sentinels in the published explain payload contract (#14900)
* docs(spec): name both zero-rows sentinels in the published explain payload contract `ExplainDecision.readFilter` and `ExplainRecordAttribution.rowFilter` published the zero-rows vocabulary as a closed two-item enumeration (`null` = unrestricted, `{ id: '__deny_all__' }` = zero rows). A fail-closed RLS denial composes plugin-security's `RLS_DENY_FILTER` — `__rls_deny__` plus a colon and a UUID-shaped suffix — and is published as composed, so the contract named one of the two shapes a consumer can actually meet. Both sites now name both shapes, state that the RLS denial is published as composed (so it can ride inside an `$and` composite on `readFilter`), and say which fields are the decision. `readFilter` carried its prose in a JSDoc block only, which no generator reads: its docs cell and its JSON Schema `description` were empty, so it gains a `.describe()`. Text only: both fields stay `z.unknown()`, no accepted value or emitted payload moves, and the two sentinels are NOT unified. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017RbbUMnxkUnWhE4j94v8FE * docs(spec): keep the `null` = unrestricted pole inside check:empty-state's window `check:empty-state` resolves a permissive-empty statement to the nearest property declaration within 8 lines. The longer JSDoc blocks pushed the "`null` = unrestricted" clause 9 lines above `rowFilter`, so the gate resolved it backwards to `outcome` and reported it UNCLASSIFIED — a red the registry could not answer, because the statement belongs to a property that is already registered. The clause now closes each block, two lines above its declaration, on one line so the detector still sees it. Prose only: both `.describe()` strings, and therefore every generated artifact, are byte-unchanged. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017RbbUMnxkUnWhE4j94v8FE --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 0169d49 commit 7317cf2

4 files changed

Lines changed: 147 additions & 9 deletions

File tree

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
---
2+
"@objectstack/spec": patch
3+
---
4+
5+
docs(spec): the published `explain` payload contract now names BOTH zero-rows sentinels
6+
7+
`ExplainDecision.readFilter` and `ExplainRecordAttribution.rowFilter` are the
8+
machine artifact behind the explain prose, and their published description
9+
enumerated the zero-rows vocabulary as a closed two-item list: `null` =
10+
unrestricted, `{ id: '__deny_all__' }` = zero rows.
11+
12+
That enumeration had grown incomplete. A fail-closed RLS denial — the "no
13+
active organization" path, which composes plugin-security's `RLS_DENY_FILTER`
14+
and is guaranteed to return zero rows — is reported with verdict `denies` and
15+
`allowed: false`, while the payload keeps reporting the predicate that was
16+
ACTUALLY composed: an `id` equality against `__rls_deny__` plus a colon and a
17+
UUID-shaped suffix. So a reader of the contract met a zero-rows shape the
18+
contract did not name.
19+
20+
Both fields now name both shapes, say that the RLS denial is published as
21+
composed (and can therefore ride inside an `$and` composite on `readFilter`),
22+
and say which fields are the DECISION — `allowed` and the `rls` layer's
23+
`verdict` for `readFilter`; `outcome` / `matchesRecord` and the layer's
24+
`verdict` for `rowFilter` — so a consumer that pattern-matches the payload
25+
alone to detect "zero rows" is told it must match both.
26+
27+
`readFilter` carried its enumeration in a JSDoc block only, which no generator
28+
reads: its published description cell and its JSON Schema `description` were
29+
both EMPTY. It now carries a `.describe()`, so the reference page and the
30+
emitted JSON Schema publish the vocabulary instead of nothing.
31+
32+
Text only. `readFilter` / `rowFilter` remain `z.unknown()`, no accepted value
33+
changes, no emitted payload changes, and the two sentinels are not unified —
34+
the sentinel vocabulary itself is a separate, deployment-facing decision.

content/docs/references/security/explain.mdx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,7 @@ ADR-0095 D2 posture rung — PLATFORM_ADMIN crosses the tenant wall where object
106106
| **operation** | `Enum<'read' \| 'create' \| 'update' \| 'delete' \| 'transfer' \| 'restore' \| 'purge' \| 'export'>` || |
107107
| **principal** | `{ userId: string \| null; positions: string[]; permissionSets: string[]; principalKind?: Enum<'human' \| 'agent' \| 'service' \| 'guest' \| 'system'>; … }` || |
108108
| **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; … }[]` || |
109-
| **readFilter** | `any` | optional | |
109+
| **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. |
110110
| **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. |
111111
| **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. |
112112

@@ -169,7 +169,7 @@ ADR-0095 D2 posture rung — PLATFORM_ADMIN crosses the tenant wall where object
169169
| Property | Type | Required | Description |
170170
| :--- | :--- | :--- | :--- |
171171
| **outcome** | `Enum<'admitted' \| 'excluded' \| 'not_evaluated'>` || This layer's row-level outcome for the record: admitted, excluded, or not_evaluated (skipped/not row-scoped). |
172-
| **rowFilter** | `any` | optional | The effective row predicate this layer contributed for the record set (null = unrestricted, __deny_all__ = zero rows). |
172+
| **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. |
173173
| **matchesRecord** | `boolean` | optional | Whether the specific record satisfies rowFilter — the judgement behind outcome. |
174174
| **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. |
175175
| **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
216216
| Property | Type | Required | Description |
217217
| :--- | :--- | :--- | :--- |
218218
| **outcome** | `Enum<'admitted' \| 'excluded' \| 'not_evaluated'>` || This layer's row-level outcome for the record: admitted, excluded, or not_evaluated (skipped/not row-scoped). |
219-
| **rowFilter** | `any` | optional | The effective row predicate this layer contributed for the record set (null = unrestricted, __deny_all__ = zero rows). |
219+
| **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. |
220220
| **matchesRecord** | `boolean` | optional | Whether the specific record satisfies rowFilter — the judgement behind outcome. |
221221
| **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. |
222222
| **detail** | `string` | optional | Human-readable, record-specific explanation of this layer's outcome. |
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#13961] The zero-rows vocabulary published on `explain`'s two payload
5+
* fields must name BOTH sentinels, and must say which field is the decision.
6+
*
7+
* Why this pin exists. `ExplainDecision.readFilter` and
8+
* `ExplainRecordAttribution.rowFilter` are the machine artifact behind the
9+
* explain prose, and their published description was a closed, two-item
10+
* enumeration: "`null` = unrestricted, `{ id: '__deny_all__' }` = zero rows".
11+
* Then the platform grew a SECOND zero-rows shape that reaches those fields —
12+
* plugin-security's fail-closed RLS denial, an `id` equality against
13+
* `__rls_deny__` plus a colon and a UUID-shaped suffix — published exactly as
14+
* composed rather than rewritten to the deny-all spelling. The enumeration a
15+
* reader would trust was then missing a member, on a diagnostic surface whose
16+
* whole purpose is telling an operator the truth about a request.
17+
*
18+
* ⛔ Scope: **the claim shape, not the wording.** Rephrasing a sentence,
19+
* reordering the clauses, or naming a third shape that later becomes
20+
* reachable is free. Dropping either sentinel from either field's published
21+
* description, or dropping the statement that the sibling verdict fields —
22+
* not the payload — are the decision, is not.
23+
*
24+
* Read THROUGH the schema (`.shape.<field>.description`), never by grepping
25+
* the source: the description is what generators publish — the reference page
26+
* at `content/docs/references/security/explain.mdx` and the emitted JSON
27+
* Schema both copy this exact string — so reading it off the schema object is
28+
* reading the published artifact's own source, and a `.describe()` that got
29+
* detached from the field (moved onto a wrapper, dropped in a refactor) shows
30+
* up here as `undefined` instead of passing on a source line that still exists.
31+
*
32+
* The RLS sentinel's prefix is pinned as a LITERAL rather than imported from
33+
* `@objectstack/plugin-security`: `packages/spec` carries no runtime
34+
* dependency (Prime Directive #2), and a spec test that reached into another
35+
* package's source would also be a cross-package test input. The producer-side
36+
* half of the agreement — that the constant really is spelled this way — is
37+
* owned by plugin-security's own suites.
38+
*/
39+
40+
import { describe, it, expect } from 'vitest';
41+
42+
import { ExplainDecisionSchema, ExplainRecordAttributionSchema } from './explain.zod';
43+
44+
/** The composed deny-all sentinel — the member the enumeration always had. */
45+
const DENY_ALL = '__deny_all__';
46+
/** The fail-closed RLS denial's marker prefix — the member it was missing. */
47+
const RLS_DENY = '__rls_deny__';
48+
49+
const descriptionOf = (schema: unknown, field: string): string => {
50+
const shape = (schema as { shape: Record<string, { description?: string }> }).shape;
51+
const description = shape[field]?.description;
52+
expect(
53+
description,
54+
`${field} must carry a .describe() — a JSDoc-only field publishes an EMPTY description cell`,
55+
).toBeTypeOf('string');
56+
return description as string;
57+
};
58+
59+
describe.each([
60+
{ field: 'readFilter', schema: ExplainDecisionSchema, decidedBy: [/\ballowed\b/, /\bverdict\b/] },
61+
{
62+
field: 'rowFilter',
63+
schema: ExplainRecordAttributionSchema,
64+
decidedBy: [/\boutcome\b/, /\bmatchesRecord\b/, /\bverdict\b/],
65+
},
66+
])('$field — the published zero-rows vocabulary', ({ field, schema, decidedBy }) => {
67+
it('names BOTH zero-rows sentinels', () => {
68+
const description = descriptionOf(schema, field);
69+
expect(description).toContain(DENY_ALL);
70+
expect(description).toContain(RLS_DENY);
71+
});
72+
73+
it('still names the unrestricted pole, so the enumeration stays complete at both ends', () => {
74+
expect(descriptionOf(schema, field)).toContain('null');
75+
});
76+
77+
it('says the sibling verdict fields — not this payload — are the decision', () => {
78+
const description = descriptionOf(schema, field);
79+
for (const marker of decidedBy) expect(description).toMatch(marker);
80+
});
81+
82+
it('tells a payload-matching consumer it must match BOTH shapes', () => {
83+
expect(descriptionOf(schema, field)).toMatch(/match both/i);
84+
});
85+
});

packages/spec/src/security/explain.zod.ts

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -137,9 +137,19 @@ export const ExplainRecordAttributionSchema = lazySchema(() => z.object({
137137
/** Whether this layer admitted the record, excluded it, or did not evaluate it. */
138138
outcome: z.enum(['admitted', 'excluded', 'not_evaluated'])
139139
.describe('This layer\'s row-level outcome for the record: admitted, excluded, or not_evaluated (skipped/not row-scoped).'),
140-
/** The effective row predicate this layer contributed (`null` = unrestricted, `{ id: "__deny_all__" }` = zero rows). */
140+
/**
141+
* The effective row predicate this layer contributed, published exactly as
142+
* the layer composed it. TWO shapes mean zero rows: `{ id: '__deny_all__' }`,
143+
* the composed deny-all sentinel, and plugin-security's fail-closed RLS
144+
* denial — an `id` equality against `__rls_deny__`, a colon, and a
145+
* UUID-shaped suffix no record can carry. Neither shape is the DECISION:
146+
* this attribution's `outcome` / `matchesRecord` and the layer's `verdict`
147+
* are, and they answer correctly for both. A consumer that pattern-matches
148+
* this payload ALONE to detect "zero rows" must match both shapes.
149+
* The open pole is unchanged: `null` = unrestricted.
150+
*/
141151
rowFilter: z.unknown().optional()
142-
.describe('The effective row predicate this layer contributed for the record set (null = unrestricted, __deny_all__ = zero rows).'),
152+
.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.'),
143153
/** Whether THIS record satisfies `rowFilter` — the row-level judgement behind `outcome`. */
144154
matchesRecord: z.boolean().optional()
145155
.describe('Whether the specific record satisfies rowFilter — the judgement behind outcome.'),
@@ -359,11 +369,20 @@ export const ExplainDecisionSchema = lazySchema(() => z.object({
359369
/** Per-layer breakdown, in pipeline order. */
360370
layers: z.array(ExplainLayerSchema),
361371
/**
362-
* For `read`: the composed row filter the caller would be served with —
363-
* the machine artifact behind the prose (`null` = unrestricted,
364-
* `{ id: '__deny_all__' }` = zero rows).
372+
* For `read` and `export` (#3544): the composed row filter the caller would
373+
* be served with — the machine artifact behind the prose. TWO shapes mean
374+
* zero rows: `{ id: '__deny_all__' }`, the composed deny-all sentinel, and
375+
* plugin-security's fail-closed RLS denial — an `id` equality against
376+
* `__rls_deny__`, a colon, and a UUID-shaped suffix no record can carry.
377+
* Only the first is rewritten to the deny-all spelling when it is composed
378+
* in; the RLS denial is published as composed, so it can also ride inside an
379+
* `$and` composite. Neither shape is the DECISION: `allowed` and the `rls`
380+
* layer's `verdict` are, and they answer correctly for both. A consumer that
381+
* pattern-matches this payload ALONE to detect "zero rows" must match both.
382+
* The open pole is unchanged: `null` = unrestricted.
365383
*/
366-
readFilter: z.unknown().optional(),
384+
readFilter: z.unknown().optional()
385+
.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.'),
367386
/**
368387
* [C2 / ADR-0090 D6] Record-grained verdict — present only when the request
369388
* carried a `recordId`. The row-level bottom line for the one concrete record:

0 commit comments

Comments
 (0)