Skip to content

fix(spec, lint): state the RLS check default per operation across the applicable policies, as the runtime applies it - #19962

Merged
hotlong merged 9 commits into
mainfrom
claude/issue-19953-rls-check-default-text
Sep 24, 2026
Merged

hotlong merged 9 commits into
mainfrom
claude/issue-19953-rls-check-default-text

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Sep 24, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #19953
Clause-②: no

Contract text only; no schema shape, accepted value or runtime behaviour changes (triage direction A, 5808181305). The published RowLevelSecurityPolicySchema.check said it "defaults to USING clause if not specified", which reads per policy. The write gate (writeCheckPolicies, since #19952) decides the default once per write operation across the applicable policies. The texts now say so. They also say the check runs on the new row of a single-record insert and of a by-id update. An array insert and a multi: true update are not post-image checked (#19964, #19950). Round 2 made that change after the review FAIL 5813145732, and limited the old 「OR-combine (most permissive wins)」 wording to reads.

  • packages/spec/src/security/rls.zod.ts: the check describe and TSDoc. The two reference pages under content/docs/references/security/ are regenerated from the describe.
  • content/docs/permissions/rls.mdx: two sentences.
  • packages/lint/src/validate-rls-predicate-enforceability.ts (domain:devx, declared on the claim): the header, and the consequence texts for using and check.
  • Changeset: @objectstack/spec and @objectstack/lint at patch.

The dev measured the runtime composition before writing; the table is in report 5812757565. The ADR-0058 D4 clarifying note was reverted out of this PR (seat ruling 5812826208), because docs/adr/** is governed; it stays owed separately. Filed from the measurement: #19964 (an array insert runs no row-level check) and #19965 (a check on a select / delete policy is never evaluated).

🤖 Generated with Claude Code

https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1

…on using default

The using-to-check default is decided per write operation across the
applicable policies, not policy by policy: when any applicable policy
declares check, only the declared checks decide and a USING-only sibling
adds nothing. The describe and TSDoc said the default applied per policy.

Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1
Co-authored-by: Claude <noreply@anthropic.com>
…cribe

Output of pnpm --filter @objectstack/spec check:generated --fix, which
proved check:docs stale and regenerated only content/docs/references/**.

Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1
Co-authored-by: Claude <noreply@anthropic.com>
The property table and the fail-closed list said an omitted check reuses
using. That holds only when no applicable policy for the operation declares
a check; when one does, only the declared checks decide.

Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1
Co-authored-by: Claude <noreply@anthropic.com>
…the write-check composition

The header said computeWriteCheckFilter collects only the policies that
declare a check. It takes its set from writeCheckPolicies: the declared
checks when any applicable policy declares one, otherwise every applicable
policy's using compiled as its check. Every using consequence now states
what a dropped using on an insert or all policy does to the insert check,
and every check consequence is qualified by the rest of the declared set.

Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1
Co-authored-by: Claude <noreply@anthropic.com>
… per policy

A dated note under D4, not an amendment: the check-to-using default is
applied across the applicable policies for a write operation, and a
USING-only policy adds nothing once any applicable policy declares check.

Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tooling labels Sep 24, 2026
@github-actions

github-actions Bot commented Sep 24, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/lint, @objectstack/spec, touching 4 documentable anchor(s).

1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/protocol/objectql/security.mdx (via RowLevelSecurityPolicySchema (symbol, a top-level const))

⛔ 2 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v13.mdx (via RowLevelSecurityPolicySchema (symbol, a top-level const))
  • content/docs/releases/v17/17-0.mdx (via RowLevelSecurityPolicySchema (symbol, a top-level const))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 60 of 215 client-bound route-ledger rows — the other 155 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 155: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 100 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 136 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 3b5607019f6b1f84b14716c9c5e3359a986de08e → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 73b42bd6732646966fa04746b38e5202909dda54 — the merge of head 22c9473c86d64d1e29b47548e2264affecd8a792 into base 3b5607019f6b1f84b14716c9c5e3359a986de08e, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 73b42bd6732646966fa04746b38e5202909dda54 && git checkout 73b42bd6732646966fa04746b38e5202909dda54
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 3b5607019f6b1f84b14716c9c5e3359a986de08e 22c9473c86d64d1e29b47548e2264affecd8a792 && git checkout -B drift-repro 3b5607019f6b1f84b14716c9c5e3359a986de08e && git merge --no-ff 22c9473c86d64d1e29b47548e2264affecd8a792

node scripts/docs-audit/affected-docs.mjs --json 3b5607019f6b1f84b14716c9c5e3359a986de08e

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 3b5607019f6b1f84b14716c9c5e3359a986de08e → pass the list as
args.docs, on the commit named under Which tree this was computed on.

…ion, not per policy"

This reverts commit 20b8fe9.

Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: f139546003c519f962ccb662c4d48b5f1b37619f

Reviewed and posted 2026-09-24T11:26Z by the at-tier review subagent the domain:spec#5 seat spawned — read: card #19953 with 5808181305 / 5811825631 / 5812757565 / 5812826208, PR #19962 diff · body · 7 commits · 46 head check-runs, #19952, #19964, #19965, ADR-0058 D4, writeCheckPolicies / computeWriteCheckFilter / step 3.6 / compileFilter, the lint rule and its test file, AGENTS.md, contract-review.md / ran: 38 policy sets × 5 write shapes × 2 SQL driver families through the real SecurityPlugin + ObjectQL in a sibling worktree at the head, the runtime's own pins (rls-check-defaults-to-using 22/22, row-write-widener-composition + controlled-by-parent-detail-write-authority 29/29), the lint rule's 86/86 pins, a byte comparison of the describe against both generated rows, a model-identifier sweep of the PR's artefacts / NOT MEASURED: check:generated locally (derived gate family — read from CI's TypeScript Type Check, green); the REST createManyData route (the engine-level array insert was measured)

① Derived judgments

Runtime under packages/plugins/plugin-security is byte-identical between base 3b5607019f and the head. Cells: fresh engine per policy set, caller usr_a positions [writer] (+manager where named), object qa_ticket (public_read_write), ground truth read back under a system context. Every cell is identical on driver-sql (better-sqlite3) and driver-sqlite-wasm, and cell-for-cell equal to report 5812757565.

  1. The composition the describe, TSDoc, both reference rows, permissions/rls.mdx (row + fail-closed item 4) and the changeset state — TRUE as measured.
    • Any applicable policy declares check ⇒ only the declared checks decide, OR-combined, a USING-only sibling adds nothing: C1/C3/C7/H1 (closed admitted, archived 403); the card's mixed case, check: status == 'open' beside USING-only status == 'pending' on insert: pending 403 (inside the sibling's using, outside the declared check); two declared checks == 'open' | == 'pending': both admitted, closed/archived 403.
    • None declares check ⇒ each applicable using stands in, OR-combined: A1/A4 (closed 403 on a single-record insert and a by-id update, open stored); two USING-only == 'open' | == 'pending': both admitted, closed/archived 403.
    • Applicable = not enabled: false (E3: a disabled check-declaring policy does not suppress the default), object match or '*' (E4 suppresses, E5 on another object does not), operation match or all (C4: insert defaulted from the all policy's using, update decided by the update check alone), positions held (E1w writer gets the defaulted using, E1m manager the declared check only; E2w non-holder is unchecked). Blank check = not declared (C8).
    • A check on a select / delete policy is never evaluated and does not suppress the default: B3/B5, C5/C6.
    • Ownership floor "takes part only where the by-id pre-image gate kept it": the runtime's own pins for the kept half (22/22) and the dropped half (29/29) are green at the head.
    • Delegation (the text makes no claim; measured for completeness): each leg is composed by the same selector and the legs AND — caller [declared != 'archived' + USING-only != 'closed'] on behalf of a delegator holding a USING-only != 'pending' set: open ok, pending 403 (the delegator's defaulted using), closed ok (the caller's sibling adds nothing), archived 403 (the caller's declared check); the same caller undelegated admits pending and closed. A missing delegator: 403.
  2. Lint header and the six consequence strings — TRUE as measured, and no finding fires differently. using half: D1/D2/G1/G2 (unlowerable shape, unresolved current_user.*, undeclared column on insert / all) refuse every single-record insert, in-scope rows included; D3 the compiling sibling using alone decides; beside a declared check the using takes no part (C cells). check half: D4 (unlowerable check + USING-only sibling) refuses every insert; D5 the compiling declared check alone decides; D7/D8 refuse every by-id update. "Single-record insert and by-id update" is the right qualifier: in every D cell the array insert and the multi-row update were admitted. The diff changes two string constants, their concatenation and comments only; the shipped strings read off the rule at the head match the diff; the pins on RLS_DENY_FILTER / ZERO rows / PermissionDeniedError / blanket refusal hold; no message carries a tracker number.
  3. Generated pages: both check rows are byte-equal to the describe (674 chars); CI TypeScript Type Check (runs check:generated) is green.
  4. ⛔ FALSE universal, fail-open direction, both drivers. content/docs/permissions/rls.mdx:63 "Predicate the new row must satisfy after an insert or update" and the describe "Validation condition for INSERT/UPDATE, matched against the new row" (→ both reference rows). Measured: B1/B4 — a declared check: record.status != 'archived' on an insert / all policy refuses a single-record insert of archived (403, nothing stored), yet engine.insert('qa_ticket', [{status:'closed'}, {status:'archived'}], {context}) stores both rows; B4 — engine.update('qa_ticket', {status:'archived'}, {where:{title}, multi:true, context}) stores [archived, archived]; A4 (USING-only all) stores [closed, closed] the same way; every D cell that refuses all single-record inserts admits the array insert. This PR's own lint strings carry the exact qualifier; the contract text and the docs row it rewrote do not. Prime Directive chore: version packages #10: the claim stays as narrow as the enforcement, bulk paths included. Fix: one clause on the describe (then regenerate) and on the docs row — "on a single-record insert and a by-id update; an array insert and a multi: true update are not post-image checked" — with [finding] security: a row-level check (declared, or defaulted from using) is never evaluated on an ARRAY insert — engine.insert(object, [rows]), which createManyData calls, stores rows a single-record insert refuses #19964 / security: a check-only row-level policy does not gate a bulk update (update(…, { where, multi: true })): the post-image check is skipped as "governed by the using-scoped where", and no using exists to scope it #19950 as the runtime carriers.
  5. Composition residue (non-blocking). (a) The priority [REMOVED] row two rows below the new check row on both reference pages (rls.zod.ts:480; the same sentence in migrations/registry.ts:160) and content/docs/protocol/objectql/security.mdx:144 still say policies "OR-combine (most permissive wins)" — false for the mixed write check (the pending 403 above); fail-closed direction. (b) security-plugin.ts:837-838 and rls-check-defaults-to-using.test.ts:5-6 quote the old describe as "the published contract"; packages/spec/liveness/permission.json:204 quotes check ?? policy.using — stale at the head; the dev listed them, the rewritten PR body dropped them, carrier none. (c) rls.zod.ts:302 and the using describe still present using as a SELECT/UPDATE/DELETE filter "optional for INSERT-only policies" without saying it is the insert check when no check is declared (A1) — incomplete, not contradictory. skills/** states no default (Tier H; nothing to do).

② Semver level

patch for @objectstack/spec and @objectstack/lint; Clause-②: no — consistent. Diff read whole: a .describe() string, TSDoc, two string constants and their concatenation, comments, the two regenerated rows, one docs page, the changeset. No schema shape, accepted value, export or runtime line moves; ADR diff at the head is empty (the note's commit is reverted, not rewritten). Check Changeset green.

③ Boundary flags

Blocking: ①.4 — the universal on permissions/rls.mdx:63 and in the describe (→ references/security/rls.mdx:175, permission.mdx:171) says every insert / update's new row is checked; the array insert and the multi: true update are not, measured on both drivers. Narrow it as above.
Non-blocking: #19964 filed, measured here (B1/B4 and the D cells) ✓ · #19965 filed, measured (B3/B5/C5/C6) ✓ — the describe's "never evaluated" is right · #19950 (multi-row update) measured A4/B4, carried there · dev Q2 (a declared check replaces the defaulted using of USING-only siblings for its holders) measured E1w vs E1m, A4 vs C3 — the text states it; direction B stays the maintainer's · ADR-0058 D4 note reverted (20b8fe95e8), owed to the maintainer with no card — track it · the stale runtime quotes and liveness evidence (①.5b) need a carrier · the "most permissive wins" residue (①.5a) — follow-up card.

CI (46 head check-runs, de-duplicated by name on latest started_at): required — Lint & Repo Gates ✓, TypeScript Type Check ✓, Dogfood Regression Gate ✓ (shards 1–3/3 ✓), Build Core ✓, Temporal Conformance (live PG + MySQL) ✓, Governed Surface Queue Guard ✓, Test Core rollup still in_progress (started 2026-09-24T11:22:30Z; shards 1–6/6 ✓ — the rollup's result is not inferred). Others ✓: the four Type Check · lanes, Build Docs, Dogfood Verify CLI, Check Changeset, Spec property liveness, Flag docs affected by code changes, Check Documentation Links, the four claim / single-writer guards, filter. Skipped: Console Pin Gate (the filter job: no console / pin path in the diff), Packed-tarball smoke (opt-in) (not opted in), the latest Auto Label / Check PR Size (edit-triggered re-runs; their synchronize-run instances ✓). Model identifiers: swept the diff, the 7 commit messages, the changeset and the PR body for every model-identifier spelling — none.

Implemented-by: claude/issue-19953-rls-check-default-text
Reviewed-by: session_01Sfe5YjBLwB9J3y8fvm2xq1 — at-tier review subagent spawned by the domain:spec#5 seat

VERDICT: FAIL


Generated by Claude Code

… limit OR-combine to reads

The check describe, its TSDoc, the permissions RLS page and the ObjectQL
security page said every insert or update's new row is checked. The
post-image check runs on a single-record insert and a by-id update; an
array insert and a multi: true update are not checked. The texts now say
so. The priority tombstone no longer cites most-permissive-wins as its
reason, and the OR-combine statements on those pages are limited to reads,
since a declared check is not widened by a USING-only sibling.

Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1
Co-authored-by: Claude <noreply@anthropic.com>
…eck and priority texts

Output of pnpm --filter @objectstack/spec check:generated --fix, which
proved check:docs stale and regenerated only content/docs/references/**.

Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 22c9473c86d64d1e29b47548e2264affecd8a792

Read: the 7-file diff 3b5607019f...22c9473c86 whole, the 9 commits, card #19953, triage 5808181305, prior FAIL 5813145732, PR body, dev-flags.json; runtime at the head: security-plugin.ts writeCheckPolicies (:880-889), computeWriteCheckFilter (:6560-6642), step 3.6 (:2996-3090), the select fallback (:6309-6318), rls-compiler.ts policyDeclaresClause (:385-391) and getApplicablePolicies (:733-776), platform-ownership-policies.ts:11, objectql/src/engine.ts:11453-11461; the lint rule and its test file; a byte comparison of the describe against both generated rows. Not run: any gate family (CI on the head is green, 46 runs); no measurement cells re-run — the runtime under plugin-security / objectql is untouched by the diff, so the prior round's cells stand.

① Derived judgments

  1. Prior blocking item ①.4 — fixed. Every universal is now narrowed to the two shapes the runtime checks:
    • describe rls.zod.ts:432 "matched against the new row of a single-record INSERT or a by-id UPDATE ... an array insert and a multi: true update are not post-image checked" — TRUE: the middleware only installs the check when !Array.isArray(opCtx.data) (security-plugin.ts:3000), so an array insert never gets the postHookWriteImageCheck seam (:3069); a bulk update with no single id sets postImage = null and skips (:3078-3084, extractSingleId).
    • content/docs/permissions/rls.mdx:57 (row), content/docs/protocol/objectql/security.mdx:144, the lint consequence strings (validate-rls-predicate-enforceability.ts:195-198, 227-229, 246-247, 265-266) carry the same qualifier. The TSDoc (:308-310) too.
  2. Composition text — every sentence TRUE of writeCheckPolicies (security-plugin.ts:884-888): declared = applicable.filter(check declared); if (declared.length > 0) return declared; else applicable.filter(using declared && (keepOwnershipFloor || !floor)), then OR-combined by compileFilter(withCheck, ctx, 'check') (:6636). "Applicable = not enabled: false" (rls-compiler.ts:753, exact === false), "object matches or is '*'" (:756), "operation matches or is 'all'" (:771-772), "caller holds one of its positions when it lists any" (:764-768, domain.length > 0 then some(held)), "blank check = not declared" (policyDeclaresClause :390, trim() !== ''). "A check on a select or delete policy is never evaluated": the write set is collected with operation = insert/update (:6597-6602), so a select/delete policy never enters it; the select fallback at :6309-6318 is inside the read/pre-image compiler, not the check path. TSDoc "ownership floor takes part only where the by-id pre-image gate kept it": :6593-6603 (keepOwnershipFloor: !floorReplaced), and owner_only_writes is '*' update only (platform-ownership-policies.ts:11), so it cannot touch an insert.
  3. Accepted/refused sets — unchanged. rls.zod.ts diff: one .describe() string, TSDoc, the file-header JSDoc bullets, and the retiredKey prescription text for priority (:476-480; still never, still refused). No z. shape, default, enum or export moves. The old prescription sentence "applicable policies OR-combine (most permissive wins), so there is no conflict to order" was false for the mixed write check; removing it is a text-only fix. No test pins the old sentence (packages/cli/test/migrate-meta.e2e.test.ts:123 is a comment; CHANGELOG.md is history).
  4. Lint (validate-rls-predicate-enforceability.ts): no behaviour change — the diff adds two string constants (USING_INSERT_CONSEQUENCE, CHECK_SET_QUALIFIER), appends them to the existing using/check consequence strings at three sites, and rewrites the header comment. No new finding, no changed trigger, no tracker number in a shipped string. The using sentence "on an insert or all policy the same using is also the single-record INSERT check whenever no applicable policy for the insert declares a check" is exactly rule 2 of writeCheckPolicies; the check qualifier is exactly rule 1 plus OR-drop. One precision nit in the header comment (not shipped text): :165-166 "(an array insert and a multi: true update never reach it)" — for a multi: true update computeWriteCheckFilter IS called (:3004, the condition at :2996-3003 does not exclude a bulk update); only the post-image evaluation is skipped (:3078-3084). The array-insert half is exact. The consequence strings authors see are precise, so non-blocking.
  5. Docs pages: permissions/rls.mdx:78-81 (fail-closed item 4) states rule 1/2 correctly; :185-187 limits OR-ed admission to reads and points at item 4; references/security/rls.mdx:81 and the removed "Policy Precedence: more permissive wins" bullet match the source JSDoc (:81, :90-92). No leftover "most permissive wins" contradiction on any page the PR touches.
  6. Generated pages — regenerated, consistent. Both check rows (references/security/rls.mdx:174, permission.mdx:171) are byte-equal to the describe after unescaping (770 chars, 2/2 match); both priority rows equal the new retiredKey text; the overview bullets and best-practice list on references/security/rls.mdx match the JSDoc header edits. Commit 22c9473 is the regen commit; CI TypeScript Type Check (check:generated) green on the head.
  7. Governed surface: no docs/adr/** path in the diff (git diff --name-only filtered for adr → empty; 20b8fe9 is reverted by f139546, net zero).
  8. Residue (non-blocking, out of this PR's named scope): (a) packages/spec/src/migrations/registry.ts:159-160 still ships "applicable policies OR-combine (most permissive wins)" in the --from 16 migration prose — the same sentence the PR removed from retiredKey; the prior review named it (①.5a) and it has no carrier. (b) rls.zod.ts:463-465 (TSDoc on priority, not rendered) still says "the doc above ... say most-permissive-wins" while the doc above now limits that to reads. (c) security-plugin.ts:837-838 quotes the old describe as "the published contract" — now stale (prior ①.5b). (d) content/docs/permissions/authorization.mdx:109 unqualified OR-combine (dev flag).

② Semver level

patch for @objectstack/spec and @objectstack/lint — correct. Diff is describe/TSDoc/JSDoc text, a retired-key message, two lint message constants and their concatenation, docs pages and the regenerated reference rows; no schema shape, accepted value, export, diagnostic code or runtime line changes. Changeset prose (.changeset/19953-rls-check-default-composition-text.md) restates rules 1/2, the applicability predicate, the single-record/by-id scope with #19964/#19950 as carriers, and Clause-②: no — every sentence checked above as true. No other package's source is touched, so no other package needs a bump.

③ Boundary flags

Implemented-by: claude/issue-19953-rls-check-default-text
Reviewed-by: session_01EcrTi7s5oDYPHS4Pi7h31d

VERDICT: PASS

Isolated at-tier reviewer adopted by the director seat, summon #29, on the maintainer's instruction 「执行契约复审」 · fed only the card(s), the governing rulings, the PR body, the dev's flags and the code


Generated by Claude Code

@hotlong
hotlong marked this pull request as ready for review September 24, 2026 17:03
@hotlong
hotlong added this pull request to the merge queue Sep 24, 2026
Merged via the queue into main with commit b5853da Sep 24, 2026
51 checks passed
@hotlong
hotlong deleted the claude/issue-19953-rls-check-default-text branch September 24, 2026 17:27
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…ing its at-tier review (objectstack-ai#19993)

Fixes objectstack-ai#19973
Clause-②: no

## 维护者速读(草稿)

**改了什么**:把 `needs:contract-review` 恢复为「PR 在等达档契约复核」的可见标记。规则落在
`contract-review.md`
新增的一节(何时挂、何时摘、谁读,并写明没有任何检查读取它);`landing-operations.md`
里「子代理起不来」那一行原地改写,点名这个标记(行数不变);`ensure-pm-labels.sh`
加回这个标签的定义(新颜色,说明写明「只是标记,没有检查读它」)。

**为什么改**:您的原话「恢复 needs:contract-review,把这句原话写进一张 skills
车道的卡」,回答的是「只作等复核标记,不作闸门」那一问。上一班达档复核子代理连续被限流,12 个 CI 全绿的 PR
在等达档复核记录,只能靠翻座位贴才看得到;现在一个过滤 `is:pr is:open label:needs:contract-review`
就能列出来,交接也不必先读座位贴。

**风险与代价(含回滚)**:不新增任何门禁:没有 check、workflow、队列守卫或巡查脚本读它,落地仍只认 `## Contract
review` 记录,标记丢了或多挂了都不会放行或拦下任何 PR。代价是派发席每个条款② PR 多两次标签写(ACCEPT 时挂,PASS
时摘)。已逐个核对本仓会写 PR 标签的 workflow:今天没有任何自动机制会摘掉手挂的 PR 标签。回滚 = revert 本
PR;GitHub 上的标签对象不受影响。

**席位意见**:(留空,待席位填写)

**你要做的**:合并后请持有 `gh` 的人跑一次 `bash scripts/pm/ensure-pm-labels.sh
--reconcile`,把现存标签对象的颜色与说明对齐(它现在是 GitHub 自动建的灰色、说明为空)。本 PR 的受管路径全在
`.claude/**`(Tier S),由席位在达档复核 PASS 后落地,不等您点合并。

## Summary

The maintainer's instruction recorded on objectstack-ai#19973 — 「恢复
needs:contract-review,把这句原话写进一张 skills 车道的卡」, answering a question that
proposed 「只作等复核标记,不作闸门」 — brings `needs:contract-review` back as a
**visibility marker, never a gate**. Every layer that ruling record
5770886272 (letter B) retired stays retired: no queue-guard refusal, no
`--pair`, no double carrier, no independence pair. The enqueue gate
still decides on the `## Contract review` record alone, and nothing in
this diff reads the label.

## What changed — 3 files, +28 / -2

| path | change |
|---|---|
| `.claude/skills/pm-dispatch/references/contract-review.md` | a new
section, heading plus 5 rule lines (:30-:36); the :3 pointer now lists
it. 28 → 36 lines, ceiling 60 |
| `.claude/skills/pm-dispatch/references/landing-operations.md` | :13
rewritten in place to name the marker. 101 / 101 lines; that line goes
113 → 119 bytes |
| `scripts/pm/ensure-pm-labels.sh` | one main-repo row after
`needs:pack-smoke`: colour `bfdadc`, a 95-character `-d`, and a comment
block naming the label's readers |

The rule as landed (contract-review.md :32-:36):

- it is only a marker, not a gate. The PR is the single carrier; a copy
on the card is outside the rule and not required.
- **hang**: at ACCEPT, if either clause-② limb hits and no same-form
PASS is on file for the current head, the dispatching seat hangs it on
the PR in the same stroke.
- **clear**: when a same-form PASS is on file for the current head, the
seat that posts it clears the marker in the same stroke. When the PR
merges or closes, the dispatching seat clears it. **A FAIL does not
clear it.**
- **readers**: the maintainer's filter `is:pr is:open
label:needs:contract-review`, and each seat's patrol and handover.
- ⛔ no check, workflow, queue guard or patrol script reads it. Enqueue
recognises only the same-form record, and the marker being present or
absent changes no verdict.

landing-operations.md :13, before and after:

```text
- 子代理起不来 ⇒ 复核缺席,PR 留 draft 队列外等档;唯一旁路是维护者亲审,逐次为准。
- 子代理起不来 ⇒ 复核缺席,PR 带 `needs:contract-review` 留 draft 队列外;旁路仅维护者逐次亲审
```

The line keeps three facts: the review is absent; the PR stays draft,
outside the queue; and the maintainer's own review is the only bypass,
per instance (唯一 … 逐次为准 → 仅 … 逐次). 「等档」 is carried by the marker itself,
which already says the PR awaits its at-tier review. Keeping 「等档」 as
well measured 125 bytes, over the 120-byte cap.

## Durability — what removes a PR label on this repo today (read at base
`ba77509eee`)

- `pr-automation.yml` job `pr-size` → `scripts/pr-labels.mjs --size`. It
POSTs the computed `size/*` label, then sends a targeted DELETE only for
stale `size/*` labels (`planSizeWrites` loops over the size family and
nothing else). The job is skipped on `labeled` / `unlabeled` / `edited`.
- `pr-automation.yml` job `auto-label` → `scripts/pr-labels.mjs
--paths`. It only POSTs: 「Path labels are ADD-ONLY … So this half issues
POST and has no DELETE at all」 (:225-:227). The keys in
`.github/labeler.yml` are documentation, `protocol:*`, ci/cd,
dependencies, tests and tooling; none is a `needs:*` label.
- `lint.yml` runs `node scripts/pr-labels.mjs --self-test`, which pins
that no write plan emits a PUT. It also runs `node
scripts/check-whole-set-label-write.mjs`, which reds on a whole-set `PUT
/issues/{n}/labels` in any spelling anywhere in the repo. That verb
(third-party labelers, and a `labels` field written through MCP) is what
removed this label in the gate era.
- `stale.yml` (`actions/stale`) removes only its own `stale` label. It
closes a PR after 37 idle days, and a close is already a clear trigger
in the rule.
- `half-state-patrol.yml` runs `sweep-closed-cards.mjs --write`, which
strips `PM_RESIDUE_LABELS` (the `pm:*` state labels) from **closed
cards** only.
- `merge-queue-triage.yml` adds labels to its anchor issues only.
`fleet-write.yml` runs only the ops a seat names.
- objectui's labeler runs with `sync-labels: true`, but that is
objectui's. This label is created in this repo only.

⇒ **Today no mechanism on this repo removes a PR label that a seat hung
by hand.** The live carriers' event history agrees. Every labeled or
unlabeled event for `needs:contract-review` on PR objectstack-ai#19962, PR objectstack-ai#19968,
objectstack-ai#19955 and objectstack-ai#19953 is by `objectstack-fleet[bot]`, that is, by a seat.
The only removal pair (PR objectstack-ai#19962 at 11:27:07Z, objectstack-ai#19953 at 11:27:41Z) was
the spec seat's own stroke after an at-tier FAIL (comment 5813182458,
「Carriers stripped on the PR and on this card」), and both were hung
again at 12:14Z. Losing a marker is also the safe failure: a waiting PR
drops out of the filter, but nothing is released, because the queue
guard reads the record.

## Live carriers at dispatch (read 2026-09-24T14:46Z) — ⛔ this PR
changes no label on any of them

| carrier | kind | what the rule says |
|---|---|---|
| objectstack-ai#19962 | PR, draft, head `22c9473c86` | path limb hits
(`packages/spec/src/security/rls.zod.ts`). The marker stays until a
same-form PASS is on file for its current head; whoever posts that PASS
clears it. Under the rule, the 11:27Z clear after the FAIL would not
happen: a FAIL leaves the marker on. |
| objectstack-ai#19968 | PR, draft, head `b05a88136d` | path limb hits
(`packages/spec/src/ui/view.form.ts`). Same as above. |
| objectstack-ai#19955 | card | a card copy is outside the rule and not required. What
happens to it is for the spec seat that hung it. |
| objectstack-ai#19953 | card | same as objectstack-ai#19955. |

Aligning these four carriers is the dispatching seat's closeout step
once the rule is on `main`, as the claim amendment on the card says. It
is not part of this PR.

## Four scripts that still name the label as retired — unchanged, on
purpose

`scripts/pm/check-half-states.mjs` :11927 and :18308,
`scripts/pm/check-skill-line-ratchet.mjs` :448 and :830,
`scripts/pm/check-widening-tells.mjs` :673, and
`scripts/pm/clause2-line.mjs` :11 and :306. Each one describes the
**gate role** (a half-state row that patrolled it, a raise provenance, a
dated census line, the ruling's summary, a measured incident). That role
is still retired, so every sentence stays true. None of them reads the
label, and this PR does not make any of them a reader. `AGENTS.md`,
`SKILL.md`, `state-machine.md` and `.claude/agents/os-dev.md` are
untouched too; the claim excluded them.

## Acceptance notes

- The filer's reading on the card calls objectstack-ai#19955 and objectstack-ai#19953 PRs. The REST
objects carry no `pull_request` key, so they are cards; the claim
amendment already reads them that way.
- 40 cards and PRs that are no longer open still carry the label from
the gate era (for example PR objectstack-ai#19666 and PR objectstack-ai#19618; 44 items in all, 4 of
them open). The filter reads `is:open` and no script reads the label, so
they are inert. Nothing in this PR touches them.
- Governed PRs on either landing tier, this one included, also wait on
an at-tier `## Contract review` record. They are outside the restored
marker's population, which is only the two clause-② limbs, the
population the retired label had. Whether to widen it is left to the
seat as an open question in the report.
- `SKILL.md`'s state-model table does not list the marker. It is a PR
label, not a card state; its rule lives in `contract-review.md`; and
`SKILL.md` is outside this PR's surface.

## Pending after merge — the seat's, not this PR's

- Someone holding `gh` runs `bash scripts/pm/ensure-pm-labels.sh
--reconcile` once. The live object is `ededed` with an empty description
(read 2026-09-24T14:46Z), and create-if-missing never changes an object
that already exists.

## Tests — on `84f4580e`

- `node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` (no paths; three-dot vs merge base
`ba77509ee`) derived 32 commands. The dispatch named four more: `node
scripts/check-skills-token-ratchet.mjs`, `node
scripts/pm/check-governed-queue-guard.mjs --self-test`, `pnpm
check:pm-expected-skips` and `pnpm check:pm-governed-prose`. **All 36
exit 0**, each exit code captured before any pipe. `--ran`
reconciliation: 「32 derived, 32 run, 0 NOT-MEASURED, 0 UNRUN」.
- `pnpm check:pm-label-desc-cap`: 「39 label descriptions … all ≤100
characters (longest: 100, tooling)」 (38 on base).
- `pnpm check:pm-skill-ratchet`: 「contract-review.md is 36 lines
(ceiling 60; headroom 24)」 and 「landing-operations.md is 101 lines
(ceiling 101; headroom 0)」. All lines are ≤120 bytes; :3 is at exactly
120.
- `pnpm check:pm-skill-id-lint`: 「34 file(s) clean」. `pnpm
check:skill-frame-sync`, `pnpm check:doc-authoring`, `pnpm
check:pm-governed-prose` and `pnpm check:nul-bytes` are green.
- `pnpm --filter @objectstack/lint run check:doc-formula-expressions`
first exited **3** (PREREQUISITE NOT MET: `@objectstack/formula` /
`@objectstack/lint` not built). That run measured nothing. After `pnpm
exec turbo run build --filter=@objectstack/formula
--filter=@objectstack/lint` under `scripts/pm/os-verify-lock.sh`
(VERDICT command-exit 0), the rerun exited 0.
- `bash -n scripts/pm/ensure-pm-labels.sh` exits 0. A fake `gh` on PATH
ran `ensure-pm-labels.sh --reconcile`, exit 0: the new row issued `label
create needs:contract-review -R objectstack-ai/objectstack -c bfdadc -d
…` and the matching `label edit … --color bfdadc --description …` with
the same string.
- `node scripts/pm/check-governed-merges.mjs --test` on the three paths
returns GOVERNED, **Tier S** (`.claude/** ×2`);
`scripts/pm/ensure-pm-labels.sh` is not on the register.
- A self-scan for control bytes on the three files finds none.
- Not run locally: no package is touched, so there is no build closure
and no package test or typecheck. `pnpm lint` and the CI-only families
(the shard attestation, the test-completeness reader and the type-check
lanes) are left to CI.

---
_Generated by [Claude
Code](https://claude.ai/code/session_01A22sUB3mUWs6M36VgfijBq)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…es them (objectstack-ai#20268)

Fixes objectstack-ai#19967
Clause-②: no

Text only. No schema shape, accepted value or runtime behaviour moves.
Every sentence below was re-measured against the code on `origin/main`
at `0d3ec47137`, where PR objectstack-ai#19962 (`b5853da1ca`), PR objectstack-ai#19988
(`009da14713`), PR objectstack-ai#20012 (`44639665ee`, objectstack-ai#19989) and PR objectstack-ai#20167
(`b276d4463f`) are all merged. Each `git merge-base --is-ancestor` probe
returned exit 0.

## What `main` enforces (the measurement the texts now state)

- **Write `check` selection, per operation.** `writeCheckPolicies`
(`packages/plugins/plugin-security/src/security-plugin.ts:890-899`)
returns the policies that declare `check` when any applicable policy
does. Otherwise it returns every applicable policy with a `using`, and
keeps the platform ownership floor only where the write's row gate kept
it. `compileFilter` (`rls-compiler.ts:600-602`) reads `check` for a
policy that declares one and `using` for the rest, then OR-combines
(`:676`).
- **A USING-only `insert` policy's `using` is the insert check** when no
applicable policy declares `check`. Pinned by
`rls-check-defaults-to-using.test.ts`, case "an insert-class USING-only
policy gates INSERT the same way", which is green on this head.
- **A check-only `update` policy is legal and enforced.** The schema
accepts it (`rls.zod.ts`, the at-least-one rule). The row gate derives
the write scope from `select` when no write-class `using` applies
(`security-plugin.ts:7018`), and the post-image check judges the written
rows. Pinned by `check-only-write-scope.test.ts`, which is green. The
showcase's `invoice_owner_immutable` is one such policy.
- **A non-blank `check` on `select` / `delete` is refused**
(`rls.zod.ts`, the `superRefine` at the `check` path, from PR objectstack-ai#20167).
- **OR-combination.** On reads, and on the rows an update or delete may
target, the applicable policies' `using` clauses OR-combine
(`compileFilter`). On the written rows, the check is chosen first and
then OR-combined. So "most permissive wins" is true on reads and false
as a statement about writes.
- **Post-image scope: every insert and update shape.** The seam is
installed for every insert and for every update
(`security-plugin.ts:3191`, `:3261`, `:3508`). The engine runs it:
- on each live row of an insert, after `beforeInsert`
(`packages/objectql/src/engine.ts:12470`);
- on the by-id row merged with the final payload, after `beforeUpdate`
(`:14085`, PR objectstack-ai#20012);
  - on each matched row of a `multi: true` update (`:14345`, PR objectstack-ai#19988).

The by-id update is also judged in the middleware on the change set as
sent (`security-plugin.ts:3116`). Pinned by
`rls-check-multi-row-writes.test.ts`,
`rls-check-by-id-update-post-hook.test.ts` and
`insert-check-post-image.test.ts`, all green on this head.

## Sites, old text, new text, and the code that makes the new text true

| Site | Old text | New text | True because |
|---|---|---|---|
| `rls.zod.ts` `check` describe | "matched against the new row of a
single-record INSERT or a by-id UPDATE ...; an array insert and a
`multi: true` update are not post-image checked." | "judged on every row
an insert or an update writes ...: each row of an insert, an array
insert included, as its `beforeInsert` hooks leave it, and each row an
update changes, by id or `multi: true`, as the prior row merged with the
final payload after its `beforeUpdate` hooks. One failing row refuses
the whole write. A by-id update is also judged, before its hooks, on the
prior row merged with the change set as sent." | `engine.ts:12470`,
`:14085`, `:14345`; `security-plugin.ts:3116` |
| `rls.zod.ts` `check` TSDoc | "Validation of the new row of a
single-record INSERT or a by-id UPDATE. An array insert and a `multi:
true` update are not post-image checked." | Every row an insert or
update writes, with the per-shape image. It also names the engine-filled
values that are not on an insert's judged image (autonumber, a `secret`
field's stored reference, an absent tenant column). | the same lines;
the engine's closed list after the insert seam (`engine.ts`, the comment
above `:12470`) |
| `rls.zod.ts` `check` TSDoc, floor | "takes part only where the by-id
pre-image gate kept it" | "only where the write's own row gate kept it"
| `computeWriteCheckFilter`: a `multi: true` update has no by-id gate
and keeps the floor unless the OWD yields
(`platformFloorYieldsToObjectWriteModel`) |
| `rls.zod.ts` `using` describe | "Filter condition for
SELECT/UPDATE/DELETE ... Optional for INSERT-only policies." | "Row
predicate ...: the rows a `select` policy lets a caller read, and the
existing rows an `update` or `delete` policy lets a caller change or
remove. On an insert or an update, when no applicable policy for that
operation declares `check`, each applicable policy's `using` also stands
in as its check on every row written ...; on an `insert` policy that is
its only effect. ... Needed on a `select` or `delete` policy (a `check`
there is refused); optional on an `insert`, `update` or `all` policy
that declares `check`." | `writeCheckPolicies:896`;
`getApplicablePolicies:846`; the `check` refusal |
| `rls.zod.ts` `using` TSDoc | "For INSERT-only policies, USING is not
required (only CHECK is needed). For SELECT/UPDATE/DELETE operations,
USING is required." | A per-operation list. `update` does not require
`using`: a check-only `update` policy is accepted and enforced, and its
target rows come from the other `update` / `all` `using` or from
`select`. An `insert` policy's `using` filters nothing, but it is the
insert check when none is declared. | `security-plugin.ts:7018`;
`check-only-write-scope.test.ts` |
| `rls.zod.ts` `superRefine` message | "... For SELECT/UPDATE/DELETE
operations, provide "using". For INSERT operations, provide "check"." |
The same head. Then: `select` / `delete` take "using"; `insert` takes
"check", or a "using" alone as that check when no applicable insert
policy declares one; `update` / `all` take either or both. | the schema
accepts each prescription (pinned below) |
| `rls.zod.ts` schema TSDoc | "combined with OR logic (union of
results)" | OR on reads; on writes, the per-operation choice (see
`check`) | `compileFilter`; `writeCheckPolicies` |
| `rls.zod.ts` `priority` TSDoc | "Applicable policies OR-combine (...
the doc above `RLSCompiler.compileFilter` and this schema's own former
describe both say most-permissive-wins)" | No outcome depends on an
order: OR on reads; on writes, the check is chosen per operation and
then OR-combined | the same |
| `rls.zod.ts` overview, "Default Deny" | "If no policy matches, access
is denied" | Default deny among the policies that apply. When no policy
applies, the policies restrict nothing (the tenant wall still applies).
| `compileFilter:656` (`applicable === 0` returns `null`, no filter);
`content/docs/permissions/rls.mdx` callout |
| `migrations/registry.ts` `--from 16` prose (and regenerated
`docs/protocol-upgrade-guide.md`) | "applicable policies OR-combine
(most permissive wins)" | "no outcome depends on an order: applicable
policies OR-combine on reads, and a write's check is chosen once per
operation across the applicable policies, then OR-combined" | the same |
| `liveness/permission.json` `check` / `using` evidence |
`compileFilter` "`(policy as { check?: string }).check ?? policy.using`"
(the expression is gone) | `security-plugin.ts#writeCheckPolicies` plus
`rls-compiler.ts#compileFilter`, with the live expression |
`check:liveness` resolves both anchors (green) |
| `security-plugin.ts` `writeCheckPolicies` docblock | "The published
contract is `RowLevelSecurityPolicySchema.check`: "defaults to USING
clause if not specified"." | It points at
`RowLevelSecurityPolicySchema.check` without quoting it, so it cannot go
stale. PostgreSQL's per-policy rule is named as the starting point. |
the describe itself |
| `rls-check-defaults-to-using.test.ts` header | "A policy that declares
no `check` holds the write post-image to its `using`, which is what
`RowLevelSecurityPolicySchema.check` publishes: "defaults to USING
clause if not specified"." | When no applicable policy declares `check`,
each `using` stands in. The choice is per operation, not per policy. |
`writeCheckPolicies` |
| `content/docs/permissions/authorization.mdx:108-109` | "Multiple row
policies for the same object/operation OR-combine" | They OR-combine
their `using` on reads and on the rows a write may target. The
written-row `check` is chosen per operation first. Links the fail-closed
contract. | the same |

### In-place fixes beyond the claim's file surface (same defect class:
the same stale sentences)

The dispatch asked for a grep for other copies of the old sentences.
These hits are not governed and not pending changesets, so they are
fixed here. **File-surface supplement for the claim:**
`content/docs/permissions/rls.mdx`,
`content/docs/protocol/objectql/security.mdx` and
`packages/spec/src/conversions/registry.ts` (TSDoc only). The two
changesets below are also outside it.

- `content/docs/permissions/rls.mdx:63`: "after a single-record insert
or a by-id update; an array insert and a `multi: true` update are not
checked" now says every row an insert or update writes.
- `content/docs/protocol/objectql/security.mdx:144`: the same
parenthetical. `using` is no longer "for SELECT/UPDATE/DELETE" only.
- `packages/spec/src/conversions/registry.ts`: the
`permission-rls-priority-removed` TSDoc said "most permissive wins". Its
`summary` (which feeds `spec-changes.json`) is unchanged and not false.
- `packages/spec/src/security/rls.test.ts`: the `priority` test comment
said "(most permissive wins)".

## Pin sweep for the `superRefine` message

① The repo-wide grep for `At least one of` and `provide "using"` finds
pins only in `packages/spec/src/security/rls.test.ts`: `toContain('At
least one of')` and its negation. Both still hold, because the head is
unchanged. No other package, doc or translation carries the message.

② New load-bearing pins, in the `RowLevelSecurityPolicySchema — the "at
least one" refusal` block of `rls.test.ts`. For each of the five
operations they assert the refusal's `code` (`custom`), `path` (`[]`)
and head, that every operation is named, and that the false sentence is
absent. They then prove each prescription against the schema itself:
`select` / `delete` + `using`, `insert` + `check`, `insert` + `using`
alone, and `update` / `all` with check only, using only, and both. All
parse.

## Pending release notes corrected (DELIBERATE CORRECTION:
`check-empty-changeset` is red by design)

- `.changeset/19953-rls-check-default-composition-text.md` (from PR
objectstack-ai#19962). It said: "The check runs on the new row of a single-record
insert and of a by-id update. An array insert and a `multi: true` update
are not post-image checked; those are tracked in objectstack-ai#19964 and objectstack-ai#19950, and
the texts now say so". That would ship false. objectstack-ai#19988 and objectstack-ai#20012 land in
the same release, and this PR changes the texts it says "now say so".
The rewrite dates the old scope to when that change was written, and
states the release's scope.
- `.changeset/rls-check-defaults-to-using.md` (from PR objectstack-ai#19952). It said:
"`RowLevelSecurityPolicySchema.check` reads "defaults to USING clause if
not specified"". The describe no longer reads that in the release this
note ships in (PR objectstack-ai#19962). Changed to "read ... (it now states the
default per operation across the applicable policies, objectstack-ai#19953)". The
triage on objectstack-ai#19967 raised this note for the dev to judge.

The gate's remedy is to say so here and get it confirmed. Restoring
either note from base would put the false sentence back.

## Reported only, not edited

- **Governed (Tier H):**
- `docs/adr/0066-unified-authorization-model.md:93`: "Multiple row
policies for the same object/operation are OR-combined". This is the ADR
sentence that `authorization.mdx` paraphrases, and it has the same
false-for-writes reading.
- `skills/objectstack-data/rules/security.md:72-75`: calls `using` the
"read filter" and `check` the "write filter", and does not state the
stand-in check.
- `docs/adr/0095-authz-kernel-tenant-layer-and-posture-ladder.md:79-81`:
about the read filter, and not false.
- **Release-owned (never edited in a code PR):**
`packages/spec/CHANGELOG.md:46934`, `:73373` and
`packages/plugins/plugin-security/CHANGELOG.md:6610`, `:10225`. These
say "(the schema's own describe says most-permissive-wins)". That was
true of the describe when they shipped.
- `packages/spec/spec-changes.json:79`, `:971`: the generated `summary`
"policies OR-combine". It is shorthand and not false. It is regenerated
from the conversion registry, which this PR leaves as is.

## Published surface

- **`@objectstack/spec`**: `patch`. It ships `src/**/*.zod.ts`, `dist`,
`liveness` and the `--from 16` prose.
- **`@objectstack/plugin-security`**: no changeset. The two edits are a
comment in a non-exported function and a test header. Measured: tsup
strips in-body comments. A positive control in
`packages/objectql/dist/index.js` shows the code line
`postHookWriteImageCheck.honoured = true` present (1 hit) and the
comment above it, "INSERT POST-IMAGE seam", absent (0 hits).
`writeCheckPolicies` is not exported, so no `.d.ts` carries its
docblock.

## Verification

Every reading below was taken on head `e2bd8f3680`, the final commit,
with a clean tree.

- **Gate union.** `node scripts/pm/dispatch-gates.mjs --repo
objectstack-ai/objectstack --commands` derived the list. `--ran`
reconciles it: "116 derived famil(ies) accounted for — 112 run, 4
NOT-MEASURED". The 116 includes 2 families the derivation added after
the regeneration commit (`pnpm --filter @objectstack/spec run
check:generated` and `pnpm check:quick-reference-counts`). Both were
run: exit 0.
  - **111 green.**
- **1 red by design:** `node scripts/check-empty-changeset.mjs --base
origin/main` exits 1 with the DELIBERATE CORRECTION class, for the two
notes named above.
  - **NOT MEASURED, each one a PREREQUISITE NOT MET (exit 3):**
- `check:skill-examples` needs a built `@objectstack/client-react` (a
36-package closure);
    - `check:dual-build-cjs-loads` needs a full `pnpm build`;
    - `check:i18n` needs the built CLI closure;
    - `check:type-check-debt` needs a whole-workspace build.
- **`@objectstack/spec` build:** exit 0, DTS included.
`check:generated`: 2 of 15 stale (`docs/protocol-upgrade-guide.md`,
`content/docs/references/**`). Regenerated with exactly
`gen:upgrade-guide && gen:docs`, then "All 15 generated artifacts are up
to date".
- **`@objectstack/spec` tests:** `vitest run --project local
--maxWorkers=2`: "Test Files 547 passed (547) · Tests 16086 passed | 2
todo".
- **`@objectstack/plugin-security` tests:** `vitest run --maxWorkers=2`:
"Test Files 139 passed (139) · Tests 2839 passed (2839)". This includes
the semantics pins cited above: `rls-check-defaults-to-using` (22
cases), `check-only-write-scope` (21), `rls-check-multi-row-writes`
(32), `rls-check-by-id-update-post-hook` (24) and
`insert-check-post-image` (25), all green.
- **`@objectstack/plugin-security` typecheck:** exit 0, test layer
included ("check:test-typecheck: OK").
- **`@objectstack/spec` typecheck: declared narrowing.** The full `pnpm
--filter @objectstack/spec typecheck` never got a turn on the shared
verify lock in about 40 minutes of queueing: 8 attempts, each exit 99.
Its three legs are covered as follows:
- the src program: the build's DTS pass compiles the entry closure,
which contains `rls.zod.ts`, `migrations/registry.ts` and
`conversions/registry.ts`;
- the test program: `check:test-typecheck` is green inside
`check:generated` on this head, and `tsconfig.test.json` includes
`src/**/*.test.ts`;
  - `check:scripts-typecheck`: no spec script is touched.

  CI's `TypeScript Type Check` runs the full command.
- **eslint, a proven narrowing:**
- ① Population: `eslint.config.mjs` lints
`**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}` minus build output, which
contains all 6 changed `.ts` files.
- ② `pnpm exec eslint --no-inline-config --format json` over those 6
files reports 6 files, 0 errors, 0 warnings, exit 0.
- ③ No config object sets `parserOptions.project`, so no rule is
type-aware, and this diff cannot move a verdict on an untouched file.
- **Control bytes:** `check:nul-bytes` is green. The self-scan `grep
-naP` for C0 control characters and DEL over the changed files exits 1
with no match.

## Acceptance notes

- **Whitespace-only `check`: the schema and the runtime disagree**
(observation, not filed; no public-door measurement, no named producer).
The at-least-one rule tests `!data.check`, so `check: ' '` satisfies it.
The runtime and the `select` / `delete` refusal read a blank clause as
absent (`policyDeclaresClause`). So a policy with only a whitespace
`check` parses and is inert. The `using` describe's "needed on a
`select` or `delete` policy" is worded as the runtime reads it.
- **`@objectstack/lint` `rls-predicate-*` messages understate the
scope**
(`packages/lint/src/validate-rls-predicate-enforceability.ts:272`,
`:318`, `:943`, `:969`). They say "the single-record INSERT check" and
"every single-record insert and by-id update ... fails". Since PR objectstack-ai#19988
and PR objectstack-ai#20012 this is true but narrow: array inserts and `multi: true`
updates are refused too. These are not false, and they are outside this
card's file surface. Carrier: none.

---
_Generated by [Claude
Code](https://claude.ai/code/session_01QcAS3qiYYZNezaxZxaUdMV)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tooling

Projects

None yet

2 participants