Repository navigation
Description
Recording only, no severity asserted — routing and ranking are triage's. Found while
implementing slice 2 of #13639 (PR #13960); deliberately NOT repaired there, because the
repair lands in packages/spec and that card's scope is explain's computation, not its
schema.
Measured
packages/spec/src/security/explain.zod.ts documents the zero-rows payload vocabulary as
a two-item enumeration, in two places:
ExplainDecisionSchema.readFilter— "the composed row filter the caller would be
served with … (null = unrestricted,{ id: '__deny_all__' }= zero rows)"ExplainRecordAttributionSchema.rowFilter— same shape, both in the JSDoc and in the
.describe()string, and the.describe()is mirrored verbatim into published docs at
content/docs/references/security/explain.mdx(two rows).
That enumeration is now incomplete. As of PR #13960, explain reports verdict denies
and decision.allowed: false for a fail-closed RLS denial, whose readFilter /
rowFilter is plugin-security's RLS_DENY_FILTER — spelled __rls_deny__ followed by a
colon and a UUID-shaped string. That payload is published as composed (deliberately —
not rewriting it is the boundary between options A and B on #13639), so a consumer reading
the schema prose sees a zero-rows shape the prose does not name.
Why it is small
⛔ No consumer is misled about the decision: the sibling verdict field says denies
and decision.allowed is false, so a reader who consults them gets the right answer.
The gap is that the payload prose under-describes the vocabulary — incomplete, not wrong.
A consumer pattern-matching on the payload alone to detect "zero rows" would miss this
shape, and that is the only measured exposure.
Why it is worth recording anyway
The schema prose is the published contract for a diagnostic surface whose entire purpose
is telling an operator the truth about a request. "The one enumeration a reader would
trust is missing a member" is exactly the shape that costs someone an hour later, and it
is cheap to close.
Scope NOT established
- ⛔ Whether the right repair is (a) naming both sentinels in the two
.describe()
strings plus their JSDoc and regenerating the docs, or (b) the larger vocabulary
unification, is not decided here. Option (b) is option C on [finding] an RLS denial caused by an unresolved variable logs NOTHING andexplainreports it asnarrowsrather thandenies— the operator debugging "this user sees no rows" gets no signal #13639 and is already
recorded there as the maintainer's — it has a measured blast radius (__deny_all__in
the published schema and docs;__rls_deny__pinned as a bound SQL parameter by two
service-analyticssuites and dispatched on by value inisTenantWallDenial,
ADR-0123 D2). This card is only the narrow prose gap, which stands whether or not C
ever happens. - ⛔ Whether any real consumer pattern-matches the payload instead of reading
verdict
is unmeasured. - Any repair touches
packages/spec, so it carries that tier and its regenerated
baselines and docs.
Related
#13639 (the card this fell out of) · PR #13960 (slice 2, which made the shape reachable
with verdict denies) · PR #13942 (slice 1) · ADR-0123 D2
Generated by Claude Code
Generated by Claude Code
Activity
claude commentedon Sep 3, 2026
Claim:
os-devsubagent of thedomain:specPM seat, sessionsession_017RbbUMnxkUnWhE4j94v8FE(dispatched under the PM claim above, comment 5522565394).claude/issue-13961-explain-zero-rows-sentinels(empty branch pushed as the write-routing probe before the first edit)./home/user/objectstack-issue-13961, cut fromorigin/main2263ca4d679026335f559184c0bed4e76d35a242.packages/spec/src/security/explain.zod.ts, plus the regenerated reference page and JSON Schema, plus one pin. No vocabulary unification, no producer change, no new exported sentinel constant.Generated by Claude Code
claude commentedon Sep 3, 2026
os-dev-report
{ "issue": 13961, "status": "done", "branch": "claude/issue-13961-explain-zero-rows-sentinels", "pr": "https://github.com/objectstack-ai/objectstack/pull/14900", "premise_still_valid": true, "summary": "Card premise re-verified on origin/main 2263ca4d6 and it held: both sites still enumerated exactly one zero-rows sentinel. Option (a) implemented - ExplainDecisionSchema.readFilter and ExplainRecordAttributionSchema.rowFilter in packages/spec/src/security/explain.zod.ts now name BOTH shapes (`{ id: '__deny_all__' }` and plugin-security's fail-closed RLS denial, `__rls_deny__` plus a colon plus a UUID-shaped suffix, published as composed and able to ride inside an $and composite on readFilter), say only __deny_all__ triggers the payload collapse, and say the DECISION is `allowed` plus the rls layer `verdict` (readFilter) / `outcome`+`matchesRecord` plus the layer `verdict` (rowFilter). H1: RLS_DENY_FILTER is frozen at plugin-security/src/rls-compiler.ts:89-92 as { id: '__rls_deny__:00000000-0000-0000-0000-000000000000' }; the concrete UUID is deliberately NOT quoted into the contract, only the prefix and the suffix's nature. H2 measured and it changed the shape of the fix: a JSDoc-only field publishes NOTHING - readFilter's docs Description cell (explain.mdx:109) and its emitted JSON Schema description were both EMPTY on main, so readFilter gained a .describe(); api-surface (export names) and authorable-surface (already listed security/ExplainDecision:readFilter) both green, so it is not a surface change. H4 regenerated with gen:schema + gen:docs; three mdx rows changed. H5 pinned. One adjacent JSDoc correction inside the block being rewritten, named with evidence in the PR body: readFilter said 'For read' while the engine publishes it for read AND export (explain-engine.ts:1414-1416, its own #3544 comment); source-only, the new describe makes no operation-scope claim. Fences honoured: no vocabulary unification, no producer touched, no new exported constant, both fields still z.unknown(). Two commits: the change, then a prose-only reshuffle after check:empty-state legitimately reddened (see tests). Assignee left as the PM set it.", "tests": "All readings at final head 031ae9c49, tree clean, branch pushed. (1) `pnpm --filter @objectstack/spec exec vitest run --maxWorkers=2 src/security/explain-zero-rows-sentinels.pin.test.ts src/security/explain.test.ts` -> 'Test Files 2 passed (2) / Tests 37 passed (37)', exit 0. (2) `pnpm --filter @objectstack/spec typecheck` -> exit 0 (tsc --noEmit + check:scripts-typecheck + check:test-typecheck 'OK - 54 file(s) / 262 error(s) / 146 pinned signature(s) held', unchanged); NOT-MEASURED guard applied: `tsc -p tsconfig.test.json --listFiles | grep -c explain-zero-rows-sentinels.pin.test.ts` = 1, so that green really covers the new file. (3) `pnpm --filter '@objectstack/spec...' build` exit 0, 34/34 declarations emitted; rebuilt AFTER the last source edit so check:api-surface reads a dist newer than src. (4) `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` (no paths passed; it took the change set from git itself) -> 72 commands from the 4 changed paths vs merge base 2263ca4d6. The family was re-run WHOLE at the final head: 71 commands, 64 exit 0, 7 PREREQUISITE NOT MET, 0 findings. All 7 are the same unbuilt-workspace prerequisite and each says so in its own text (check-dev-prereqs, check-test-completeness exit 3, check:doc-formula-expressions and check:doc-security-posture exit 3, check:skill-examples, check:dual-build-cjs-loads exit 3, check:type-check-debt exit 3) - recorded as NOT MEASURED, never as red; only spec and its closure were built locally, the workspace build is CI's. DECLARED NARROWINGS: the 72nd command, `node scripts/pm/check-half-states.mjs`, was stopped by PID after 3m09s and excluded - it is the LIVE board sweep owned by the scheduled half-state-patrol.yml, report-only by construction, and CI runs only its offline self-test `pnpm check:pm-half-states`, which is separately in the family and passed (filed as #14899); repo-wide `pnpm lint` not run locally, that is CI's run. ONE REAL RED, found and fixed: the first sweep reported check:empty-state UNCLASSIFIED at explain.zod.ts:142 resolved to `outcome` - that gate binds a permissive-empty statement to the nearest property within 8 lines and the longer JSDoc pushed '`null` = unrestricted' to 9 lines above rowFilter; the clause now closes each block two lines above its declaration, on one line, and the gate reports 'all classified (2 closed, 2 open, 4 output, 9 scope)'. That second commit is prose-only: both describe strings and every generated artifact are byte-unchanged by it, re-confirmed by check:generated / check:docs / check:authorable-surface green at the final head. REVERSE VERIFICATION of the pin, fix committed first, predicted direction RED: explain.zod.ts restored to its pre-fix text at 2263ca4d6, mutation confirmed on disk BEFORE running anything (__rls_deny__ occurrences 4 -> 0; working-tree blob be1a08bbb != HEAD blob b827898ce) -> the pin failed 7 of 8 cases; the lone survivor is rowFilter 'still names the unrestricted pole', which the old text also satisfied, and all four readFilter cases went red on the missing .describe() (the H2 measurement as an assertion). Restore leg: `git checkout HEAD -- ABSOLUTE_PATH` inside an EXIT/INT/TERM trap with the path resolved from `git rev-parse --show-toplevel`, then proven byte-identical - hash b827898ce equal to the HEAD blob, `git diff HEAD` empty, `git status --porcelain` empty. No build/dist is in this pin's resolution path (vitest reads src), so no dist preflight applies. Exit codes were captured before any pipe throughout; every verdict quoted is the gate's own printed verdict line. Heavy runs went through scripts/pm/os-verify-lock.sh (shared box - the seconds are shared-box seconds).", "mcp_calls": "0 - the repo-scoped REST probe came back 200 at the start of the run, so every GitHub read and write went through container curl on the REST channel (card read via the public-repo zero-quota page payload; claim comment, finding issue, draft PR, and the report comment via REST). No MCP GitHub tool was called.", "open_questions": [], "out_of_scope_findings": [ "filed as #14899: dispatch-gates derives the SCHEDULED, report-only live half-state board sweep (node scripts/pm/check-half-states.mjs, from half-state-patrol.yml) into the per-PR gate family for any diff carrying a changeset - CI runs only that script's offline self-test on a PR, so the derived list names a command CI never runs there; measured at 3m09s of dead time blocking the rest of the sweep. Unassigned, no labels, deduped against all 505 open issues (complete enumeration, REST list pages 1-6, page 7 empty) before filing." ] }Generated by Claude Code