docs(spec): name both zero-rows sentinels in the published explain payload contract - #14900
Conversation
…yload 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
…ate'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
📓 Docs Drift Check2 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run. What this run could not see
Coarse fallback — 128 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 76be23c48c763ac403d3e4012cd4e0062df05d68 && git checkout 76be23c48c763ac403d3e4012cd4e0062df05d68
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 2b9f5810b777ee9d0211d9d6273d854bd8e2d5bd 031ae9c4971f6266c5c243c2d5a7b24f976b38e7 && git checkout -B drift-repro 2b9f5810b777ee9d0211d9d6273d854bd8e2d5bd && git merge --no-ff 031ae9c4971f6266c5c243c2d5a7b24f976b38e7
node scripts/docs-audit/affected-docs.mjs --json 2b9f5810b777ee9d0211d9d6273d854bd8e2d5bd |
|
Landing provenance (seat, 09:08Z):
⇒ ready for review + auto-merge (squash). Queue policy: a kick on a signature that is not this diff's gets one re-queue with a receipt; a second kick ⇒ the card goes Generated by Claude Code |
Fixes #13961
The published
explainpayload contract enumerated the zero-rows vocabulary as a closed two-item list —null= unrestricted,{ id: '__deny_all__' }= zero rows — on both fields that carry a composed row predicate. That enumeration had grown incomplete:explainalso publishes plugin-security's fail-closed RLS denial, unrewritten. This PR names both shapes at both sites, regenerates the reference page and the emitted JSON Schema, and pins the claim.Option (a) of the card only. No vocabulary unification (option C on #13639 is the maintainer's), no producer change, no new exported constant, and
readFilter/rowFilterstayz.unknown()— the accept set and the emitted payload are byte-unchanged.The two zero-rows sentinels
{ id: '__deny_all__' }buildReadFilter/buildWriteFilter(sharing-service.ts:419,:499;sharing-plugin.ts:1082), plus the explain engine's owncomputeRlsFiltercatch fallbackreadFilter(explain-engine.ts:1371)rlslayerverdict: 'denies';decision.allowed: false{ id: '__rls_deny__:' + a UUID-shaped suffix }RLS_DENY_FILTER, frozen inplugin-security/src/rls-compiler.ts:89-92; reached by the fail-closed RLS path (applicable policies exist but none compiles against the context — e.g. no active organization), named atobjects/default-permission-sets.ts:555and:719idequality against a UUID-shaped string no record carries, so the SQL layer returns zero rows without raising. Published as composed — the payload collapse is deliberately keyed to__deny_all__alone — so it can also ride inside an$andcomposite onreadFilterrlslayerverdict: 'denies';decision.allowed: false(since PR #13960)Both rows end in the same place, and that is what the new text says: a reader who consults
verdict/decision.allowed— or, per record,outcome/matchesRecord— is never misled. The gap was only for a consumer pattern-matching the payload itself, which the contract now tells to match both shapes.The concrete UUID suffix is deliberately NOT quoted in the contract text: the prefix plus the suffix's nature is the stable half, and pinning the literal value into the published spec would be a step toward the vocabulary decision this PR does not take.
What changed, per site
Both sites are in
packages/spec/src/security/explain.zod.ts.1.
ExplainRecordAttributionSchema.rowFilter— JSDoc and.describe(). The describe is the published face; here it is in full:2.
ExplainDecisionSchema.readFilter— JSDoc, plus a.describe()it did not have:Each JSDoc block carries the same claim at more length, plus the detail that only
__deny_all__triggers the payload collapse.Measured: a JSDoc-only field publishes NOTHING.
readFiltercarried its whole enumeration in a JSDoc block, and no generator reads property-level TSDoc. Onmainits Description cell incontent/docs/references/security/explain.mdx:109was empty and the emittedjson-schema/security/ExplainDecision.jsoncarried nodescriptionforreadFilterat all — so the field a consumer most needs the vocabulary for published no vocabulary. The.describe()closes that, and it moves no surface baseline:api-surface/records export names, andauthorable-surfacealready listedsecurity/ExplainDecision:readFilter. Both gates ran green (below).One adjacent correction, named rather than slipped in. The
readFilterJSDoc opened "Forread:". The engine publishes the field forreadandexport—explain-engine.ts:1414-1416, whose own[#3544]comment says why — so the JSDoc now reads "Forreadandexport(#3544)". Source-only: the new.describe()deliberately makes no claim about which operations carry the field, so nothing new about operation scope is published. Boundary scan for that class:readFilterandrowFilterare the only two properties in this file whose prose enumerates payload values, and both are rewritten here.Consumers that pattern-match the payload — the card's unmeasured question
Measured by grep across
packages/**, non-test sources, for__deny_all__,__rls_deny__,readFilterandrowFilter:ExplainDecision.readFilterorExplainRecordAttribution.rowFilterat all. Every non-test occurrence of either field name underpackages/**is insideplugin-security/src/explain-engine.ts— the producer — or insidepackages/specitself (the schema, the authorable-surface baselines, the liveness empty-state registry).__deny_all__sites are producers (plugin-sharing/src/sharing-service.ts,sharing-plugin.ts) or prose in doc comments (plugin-audit/src/comment-access-hooks.ts:145,service-storage/src/attachment-access-hooks.ts:478,service-analytics/src/read-scope-sql.ts:1009). The remaining__rls_deny__sites are the producer,service-analytics's two bound-SQL-parameter suites, and lint/formula prose. None consumes the explain payload.objectuiis not attached to this session, so nothing here measures it; the dispatch carried the PM's reading for that repo.The card's own reading therefore stands, and it is now recorded rather than assumed: the exposure was the prose, not a misled consumer.
Regenerated artifacts
Regenerated with the spec tooling, never by hand:
pnpm --filter @objectstack/spec gen:schema— rebuilds the gitignoredpackages/spec/json-schema/tree the reference pages render from.authorable-surface.base.jsonunchanged;authorable-defaults/verified against upstream, 1236 defaults unchanged.pnpm --filter @objectstack/spec gen:docs—content/docs/references/security/explain.mdx(merge=os-regen), three rows:ExplainDecision.readFilterat:109(empty cell to the new text) and the twoExplainRecordAttribution.rowFilterrows at:172and:219.The emitted JSON Schema was read back to confirm the text reached it:
json-schema/security/ExplainDecision.jsonnow carries adescriptionatproperties/readFilter(absent before) and at its nestedrowFilter, andjson-schema/security/ExplainRecordAttribution.jsonat:15.The pin
packages/spec/src/security/explain-zero-rows-sentinels.pin.test.ts— eight cases over both fields, reading the description through the schema (SchemaName.shape.FIELD.description), never by grepping the source: the description object is what the generators copy, and a.describe()detached from its field surfaces here asundefinedinstead of passing on a source line that still exists. It pins the claim shape, not the wording — both sentinel names, the unrestricted pole, the decision fields, and the "must match both" statement; rephrasing is free, dropping a member is not. The RLS prefix is a literal rather than an import:packages/speccarries no runtime dependency (Prime Directive #2) and a cross-package read would be a declared test input. The producer-side half — that the constant really is spelled this way — is owned by plugin-security's suites.Verification record
Every reading below is at the final commit of this branch,
031ae9c49(worktreeobjectstack-issue-13961, cut fromorigin/main2263ca4d6). Heavy runs went throughscripts/pm/os-verify-lock.sh; the wall-clock figures are shared-box seconds, not idle-box seconds. Exit codes were captured before any pipe, and every verdict quoted is the gate's own printed verdict line.pnpm --filter @objectstack/spec exec vitest run src/security/explain-zero-rows-sentinels.pin.test.ts src/security/explain.test.tspnpm --filter @objectstack/spec typechecktsc --noEmit,check:scripts-typecheck, andcheck:test-typecheck("54 file(s) / 262 error(s) / 146 pinned signature(s) held", unchanged, so the new test adds no debt).tsc -p tsconfig.test.json --listFilesconfirms the new pin file IS in the program, so that green is about itpnpm --filter '@objectstack/spec...' buildcheck:api-surfacereads a dist newer than src)node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack2263ca4d6031ae9c49The 7 non-zero results are NOT MEASURED, not red, and each says so in its own output — all seven are the same prerequisite, an unbuilt workspace, which is CI's to satisfy:
check-dev-prereqs("The workspace is not built — 1 unmet precondition, not a list of problems"),check-test-completeness(exit 3, "this gate grades a savedturbo run testlog, and no log was named" — its text names this exact situation and says to record it as NOT MEASURED),check:doc-formula-expressionsandcheck:doc-security-posture(exit 3, need@objectstack/formula/@objectstack/lintbuilt),check:skill-examples("packages/client-react/distholds no .d.ts declarations"),check:dual-build-cjs-loads(exit 3, 85 packages with nodist/), andcheck:type-check-debt(exit 3, "54 workspace dependenc(ies) … have no built type entry point on disk"). Only@objectstack/specand its dependency closure were built here.Two declared narrowings. (1) The 72nd derived command,
node scripts/pm/check-half-states.mjs, was stopped by PID after 3m09s and is excluded from the 71: it is the LIVE board sweep from the scheduledhalf-state-patrol.yml, report-only by construction, and CI runs only its offline self-testpnpm check:pm-half-states— which is separately in the derived set and passed. Filed as a finding (link below). (2) Repo-widepnpm lintwas not run locally; that is CI's run.One real red, found and fixed here. The first sweep reported
check:empty-stateUNCLASSIFIED atexplain.zod.ts:142, resolved tooutcome. Cause: that gate attributes a permissive-empty statement to the nearest property within 8 lines, and the longer JSDoc pushed the "null= unrestricted" clause 9 lines aboverowFilter, so it resolved backwards onto the previous property — a red the registry could not answer, because the statement belongs to a property already registered. The clause now closes each block two lines above its declaration, on one line so the detector still sees it (commit031ae9c49). Prose only: both.describe()strings, and therefore every generated artifact, are byte-unchanged by that second commit.check:empty-statenow reports "all classified (2 closed, 2 open, 4 output, 9 scope)".Reverse verification of the pin, with the fix committed first. Predicted direction: RED. The schema file was restored to its pre-fix text at
2263ca4d6, and the mutation was confirmed on disk before anything ran —__rls_deny__occurrences 4 to 0, working-tree blobbe1a08bbbdifferent from the HEAD blobb827898ce. The pin then failed 7 of its 8 cases. The one survivor isrowFilter"still names the unrestricted pole", which the pre-fix text also satisfied; every case carrying the new claim went red, and all fourreadFiltercases went red on the missing.describe()— the JSDoc-publishes-nothing measurement, stated as an assertion. Restore leg:git checkout HEAD -- ...inside anEXIT/INT/TERMtrap against an absolute path resolved fromgit rev-parse --show-toplevel, then proven byte-identical — working-tree hashb827898ceequal to the HEAD blob,git diff HEADempty,git status --porcelainempty.Out of scope, filed not fixed
dispatch-gatesderives the scheduled, report-only live half-state board sweep 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 on this container, blocking the rest of the sweep behind it. Deduped against all 505 open issues before filing.🤖 Generated with Claude Code
https://claude.ai/code/session_017RbbUMnxkUnWhE4j94v8FE
Generated by Claude Code