Skip to content

[finding] the published explain schema names only one zero-rows payload sentinel, and explain can now publish the other #13961

Closed

Description

@claude

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

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

  1. added
    documentationImprovements or additions to documentation
    pm:queueReady for the PM dispatch loop
    domain:specDomain lane — seat card indexed by label:pm:seat
    on Aug 31, 2026
  2. added
    pm:dispatchedDispatched to a dev agent by /pm-dispatch
    and removed
    pm:queueReady for the PM dispatch loop
    on Sep 3, 2026
  3. claude commented on Sep 3, 2026

    @claude
    ContributorAuthor

    Claim:

    • Agent: os-dev subagent of the domain:spec PM seat, session session_017RbbUMnxkUnWhE4j94v8FE (dispatched under the PM claim above, comment 5522565394).
    • Branch: claude/issue-13961-explain-zero-rows-sentinels (empty branch pushed as the write-routing probe before the first edit).
    • Worktree: /home/user/objectstack-issue-13961, cut from origin/main 2263ca4d679026335f559184c0bed4e76d35a242.
    • Scope: option (a) only — name both zero-rows sentinels in the published explain contract text at the two sites in 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.
    • Clause-②: no
    • Assignee: left as the PM set it; never written by this agent.

    Generated by Claude Code

  4. claude commented on Sep 3, 2026

    @claude
    ContributorAuthor

    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

  5. removed
    pm:dispatchedDispatched to a dev agent by /pm-dispatch
    on Sep 3, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    documentationImprovements or additions to documentationdomain:specDomain lane — seat card indexed by label:pm:seatpriority:p2Medium: important, M3

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions