Skip to content

fix(spec): state the RLS using / check texts as the write gate enforces them - #20268

Merged
objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-19967-rls-using-check-texts
Sep 27, 2026
Merged

objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-19967-rls-using-check-texts

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #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 #19962 (b5853da1ca), PR #19988 (009da14713), PR #20012 (44639665ee, #19989) and PR #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 feat(spec)!: refuse a check on a select / delete row-level security policy #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:

    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)

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 fix(plugin-security, objectql): a row-level check holds for every row of an array insert and a predicate update #19988 and PR fix(plugin-security, objectql)!: a by-id update's row-level check holds for the row it stores #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

… them

The check describe, TSDoc and the texts that quote them now say every row
an insert or an update writes is judged, a check-only update policy is
legal, an insert policy's using is its check when none is declared, and
OR-combination without qualification holds on reads only.

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

github-actions Bot commented Sep 27, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/plugin-security, @objectstack/spec, touching 1 documentable anchor(s). ⚠️ 3 changed file(s) yielded no anchor (packages/plugins/plugin-security/src/security-plugin.ts, packages/spec/liveness/permission.json, packages/spec/src/conversions/registry.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

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
  • 3 changed file(s) yielded no anchor (packages/plugins/plugin-security/src/security-plugin.ts, packages/spec/liveness/permission.json, packages/spec/src/conversions/registry.ts) — pages documenting those are invisible to this run
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 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; 97 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 — 139 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 17bd31877109b7cc692e7e54c4fe39f82a5c32d5 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 8b5999e053334a30d46c0f3223a1672f003b5727 — the merge of head 7bfc993cb58342b79f4bf4990e4f9e77189bad2c into base 17bd31877109b7cc692e7e54c4fe39f82a5c32d5, 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 8b5999e053334a30d46c0f3223a1672f003b5727 && git checkout 8b5999e053334a30d46c0f3223a1672f003b5727
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 17bd31877109b7cc692e7e54c4fe39f82a5c32d5 7bfc993cb58342b79f4bf4990e4f9e77189bad2c && git checkout -B drift-repro 17bd31877109b7cc692e7e54c4fe39f82a5c32d5 && git merge --no-ff 7bfc993cb58342b79f4bf4990e4f9e77189bad2c

node scripts/docs-audit/affected-docs.mjs --json 17bd31877109b7cc692e7e54c4fe39f82a5c32d5

⚠️ 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 17bd31877109b7cc692e7e54c4fe39f82a5c32d5 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: 84/84 CONTRACT_REVIEW_TIER
Head-sha: e2bd8f36806b05dcf16d4ada01aa5d02bc98233d

① Derived judgments

  1. Head unmoved (e2bd8f3680); fix(spec, lint): state the RLS check default per operation across the applicable policies, as the runtime applies it #19962/fix(plugin-security, objectql): a row-level check holds for every row of an array insert and a predicate update #19988/fix(plugin-security, objectql)!: a by-id update's row-level check holds for the row it stores #20012/feat(spec)!: refuse a check on a select / delete row-level security policy #20167 shas are ancestors (exit 0). Sentence by sentence, at head:
  • check judges every row an insert/update writes, arrays and multi: true included, one failing row refuses all: TRUE — seam security-plugin.ts:3191 (insert), :3261 (every update); run at engine.ts:12470 (each live row after beforeInsert), :14085 (by-id, prior + payload after hooks), :14345 (each matched row); denyCheck() on the first failing row (:3170-3176).
  • by-id also judged before hooks on prior row + change set as sent: TRUE — :3116-3209.
  • check-only update legal and enforced: TRUE — the only check refusal is on select/delete (rls.zod.ts:569-572); computeWriteCheckFilter → writeCheckPolicies:894; target rows derive from select when no write-class using applies (:7018-7027).
  • insert using is the check when none declares check: TRUE — writeCheckPolicies:896-898; rls-compiler.ts:600-602 reads using on the check pass.
  • non-blank check on select/delete refused: TRUE — rls.zod.ts:569-572 (trim() !== '').
  • OR on reads; writes chosen per operation then OR: TRUE — compileFilter:676; selection is a filter, order-free.
  • using describe/TSDoc, priority and schema TSDoc, --from 16 prose and guide, conversions/registry.ts:1919, permission.json (both anchors resolve; liveness green), writeCheckPolicies docblock, test header, authorization.mdx, rls.mdx:63, objectql/security.mdx:144: TRUE. Minor: the insert engine-filled list omits normalizeMultiValueFields (closed list above :12470); not false.
  1. Default Deny (SECURITY). Trace: getApplicablePolicies (enabled/object/positions/operation) empty → computeLayeredRlsFilter never compiles (non-empty guard :7044) → Layer 1 null; computeWriteCheckFilter returns null at withCheck.length === 0 (:7314); RLS_DENY_FILTER arms only when applicable policies exist and all drop (rls-compiler.ts:657-671, :7085). No default-deny switch exists (RLSConfig removed). The tenant wall AND-composes independently (:7100). The new sentence is TRUE in every configuration traced; the old "access is denied" was FALSE in the dangerous direction (a caller outside every policy's positions is open). Incomplete only in the safe direction (reader over-estimates exposure): (a) an update/delete with no write-class using is still bounded by the caller's select policies (:7018), which the schema's applicability definition does not count as applying to the operation; (b) the OWD sharing read filter (:5193) and the platform floor also narrow; (c) viewAllRecords/modifyAllRecords skip Layer 1 (:6876). The PR's using TSDoc states (a); rls.mdx states the rest. Not blocking; follow-up clause: "for an update or delete, the caller's select policies bound the target rows when no write-class using applies".
  2. superRefine message: every prescription parses (only two refinements exist). Pins rls.test.ts:744-796 assert head, all five operation names, absence of the old sentence, the four prescription phrases, and parse each shape (select/delete+using, insert+check, insert+using, update/all ×3); a regression reddens not.toContain and each toContain. Existing :592/:606 pins hold.
  3. DELIBERATE CORRECTION, confirmed; the Check Changeset log names exactly these two M rows, rule 1 green.
  1. .changeset/19967-rls-using-check-texts.md: every sentence TRUE; spec: patch right (shipped text); no plugin-security note is right (writeCheckPolicies unexported :890; test unshipped). Loose but not false: the overview never said "most permissive wins", only unqualified OR.
  2. rls.zod.ts with comments and strings stripped differs from main only in the message literal; generated pages and guide mirror their sources verbatim; no governed path (Governed guard green). CI at poll: Build Core, Dogfood, Temporal, Type Check source/consumer/debt, liveness, doc links green; Lint & Repo Gates, Test Core 1/2/3/5/6, Type Check workspace in progress; only Check Changeset red, by design.

② Semver level

Clause-②: no correct: accept set unchanged (schema tokens identical; the superRefine message is refusal prose, not a rule); @objectstack/spec patch; nothing else publishes. No BREAKING banner or ADR-0087 marker owed.

③ Boundary flags

  • ADR-0066:93 "OR-combined" as a write statement — governed Tier H, untouched, false for writes; the seat files the card.
  • skills/objectstack-data/rules/security.md:72-75 read/write-filter wording, no stand-in rule — governed, untouched; same card.
  • ADR-0095:79-81 — read-side, not false; no action.
  • Lint rls-predicate-* "single-record" (validate-rls-predicate-enforceability.ts:272,318,943,969) — true but narrow since fix(plugin-security, objectql): a row-level check holds for every row of an array insert and a predicate update #19988/fix(plugin-security, objectql)!: a by-id update's row-level check holds for the row it stores #20012; out of surface, note only.
  • Whitespace-only check parses and is inert — real, no reach; note stands (a whitespace using on a select policy instead fails closed).
  • Released CHANGELOG entries — release-owned, rightly untouched.
  • File-surface supplement (rls.mdx, objectql/security.mdx, conversions/registry.ts TSDoc) — same stale sentences, text-only, seat-accepted.
  • Default-deny incompleteness (①.2) — safe direction; one-line follow-up candidate.

Implemented-by: claude/issue-19967-rls-using-check-texts
Reviewed-by: session_01QcAS3qiYYZNezaxZxaUdMV

VERDICT: PASS

…a deleted card

The `check` TSDoc cited #16608, which no longer resolves on the board.
Anchor the seam to commit a016f08, which introduced it, and keep the
live numbers that extended it.

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

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: 78/78 CONTRACT_REVIEW_TIER
Head-sha: 7bfc993cb58342b79f4bf4990e4f9e77189bad2c

① Derived judgments

  1. Delta. PR re-read: head unmoved at 7bfc993cb5. git diff e2bd8f3680 7bfc993cb5 --stat is exactly packages/spec/src/security/rls.zod.ts | 5 +++--. Old: "OperationContext.postHookWriteImageCheck seam (plugin-security: the insert-side RLS check post-image is the raw caller payload evaluated before beforeInsert, so a field a hook stamps can never satisfy an insert check — the caller must send the value the hook will overwrite anyway #16608, 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, [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 by-id UPDATE's row-level check is judged on the pre-hook change set, so a beforeUpdate stamp that rewrites a checked field (e.g. the organization derived from a re-pointed parent) is stored unjudged — the tracker #16790 now answers 404 #19989):". New: "OperationContext.postHookWriteImageCheck seam (introduced for inserts by commit a016f08, whose original card no longer resolves; extended by 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, [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 and security: a by-id UPDATE's row-level check is judged on the pre-hook change set, so a beforeUpdate stamp that rewrites a checked field (e.g. the organization derived from a re-pointed parent) is stored unjudged — the tracker #16790 now answers 404 #19989):". Anchor: git log -S postHookWriteImageCheck --oneline origin/main -- packages/objectql lists 44639665ee, 1f05ea4fb2, 009da14713, a016f08b8a; the oldest, a016f08b8a (PR fix(plugin-security)!: evaluate the insert-side RLS check on the row that will be stored, after beforeInsert #16805, "evaluate the insert-side RLS check on the row that will be stored, after beforeInsert"), adds postHookWriteImageCheck?: PostHookWriteImageCheck to OperationContext and the insert() run; it is on origin/main and the 10-hex prefix is unique. Sentence TRUE: plugin-security: the insert-side RLS check post-image is the raw caller payload evaluated before beforeInsert, so a field a hook stamps can never satisfy an insert check — the caller must send the value the hook will overwrite anyway #16608 answers 404; 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 and [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 are closed issues (closed by PR fix(plugin-security, objectql): a row-level check holds for every row of an array insert and a predicate update #19988, 009da14713: array insert and multi: true), security: a by-id UPDATE's row-level check is judged on the pre-hook change set, so a beforeUpdate stamp that rewrites a checked field (e.g. the organization derived from a re-pointed parent) is stored unjudged — the tracker #16790 now answers 404 #19989 closed by PR fix(plugin-security, objectql)!: a by-id update's row-level check holds for the row it stores #20012 (44639665ee: by-id post-hook) — the cards under which the seam was extended. No replacement number guessed. Citation gate run in a throwaway worktree at head (--base origin/main, board probed): 80 citations across 38 files, 78 resolve, 2 cross-repo-unjudged, exit 0. CI Lint & Repo Gates (its carrier, lint.yml:4971) green at this head.
  2. Code unchanged. rls.zod.ts at head vs origin/main with comments and string literals stripped: one hunk, the superRefine message literal (five concatenated literals). main has not touched rls.zod.ts since the merge base 0d3ec47137.
  3. DELIBERATE CORRECTION at THIS head. git diff e2bd8f3680 7bfc993cb5 -- .changeset/ is empty; git diff 0d3ec47137 origin/main on both notes is empty. Check Changeset log at this head names exactly .changeset/19953-rls-check-default-composition-text.md and .changeset/rls-check-defaults-to-using.md as M rows; rule 1 green.
  4. Other red checks: none. At poll: 27 success, 2 skipped (Console Pin Gate; Packed-tarball smoke, opt-in), Check Changeset red by design, 3 in progress (Test Core 3/6, 6/6; Type Check workspace) on source identical to e2bd8f3680, where those families ran green.

② Semver level

readClause2Line on the live body: kind declared, value no, arm null, line "Clause-②: no". Correct: the accept set is unchanged (stripped schema identical to main except refusal prose); @objectstack/spec patch in 19967-rls-using-check-texts.md fits shipped text (src/**/*.zod.ts, liveness, --from 16 prose); writeCheckPolicies is unexported, so no plugin-security note is owed. No BREAKING banner or ADR-0087 marker owed.

③ Boundary flags

  • permission.json #13003 (21 sites, 404 today): 21 on main too; the PR's two note edits carry the pre-existing "RE-ANCHORED ([worklist] Migrate liveness line citations to symbol anchors — census: 117-173 of 298 live line-cited pairs fail key-proximity at line granularity today #13003)" text forward and add only #19967 and #19952; the gate's surfaces are packages/**/src/**/*.ts(x) and content/docs/releases/**, so JSON is outside it. Not this PR's defect.
  • PR body, Verification: "taken on head e2bd8f3680, the final commit" is no longer the final commit; the readings hold (the later commit is one TSDoc comment). Body-only; one-line fix: name 7bfc993cb5 as head and e2bd8f3680 as the measured commit.
  • #16608 still cited 6 times in engine.ts and security-plugin.ts sources (6 on main), on lines this PR did not edit; the diff-scoped gate does not judge them. Not this PR's defect.
  • rls-check-defaults-to-using.md still cites #16790 and #16805 (both 404 via the API today) on lines this PR did not edit; .changeset/ is outside the gate. Pre-existing.
  • Acceptance notes (whitespace-only check; lint rls-predicate-* "single-record" wording) unchanged since e2bd8f3680; still true, still note-only.
  • Neither #16608 nor a016f08b8a appears in content/docs/references/** or the upgrade guide at either head: the TSDoc change made no generated artifact stale.

Implemented-by: claude/issue-19967-rls-using-check-texts
Reviewed-by: session_01QcAS3qiYYZNezaxZxaUdMV

VERDICT: PASS

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review September 27, 2026 18:15
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Sep 27, 2026
Merged via the queue into main with commit 3f86dc5 Sep 27, 2026
36 of 37 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-19967-rls-using-check-texts branch September 27, 2026 18:38
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…nd the 25 missing entries (objectstack-ai#20201) (objectstack-ai#20255)

Fixes objectstack-ai#20201
Clause-②: no

## What

Ruling B on objectstack-ai#17152 (director `5615360777`, restated `5634031140`, on the
maintainer's objectstack-ai#15954 authority `5559778263`): every retirement family
carries ONE ADR-0087 D3 (`semantic`) entry, even when a lossless D2
conversion repairs its data; D2 carries the mechanical repair only. This
PR takes the major-18 family census the card asks for, adds the 25 D3
entries it found missing, corrects the prose that justified their
absence, and pins the census in the registry's own test.

- **25 new D3 entries** under
`packages/spec/src/migrations/entries/semantic/18.*.ts`, one per
D2-backed family that had none. Each names its family and its D2
conversion id, says what D2 already repairs, and says what judgment the
consumer still owes (the `reason`), with an `acceptanceCriteria` the
consumer can check. None is a placeholder: every one states a residue
specific to its family (a unit only the author knows, a belief the
platform never honoured, a shape the conversion deliberately leaves
alone, code the chain cannot reach).
- **Prose corrected** at the sites of `5854110917` and `5854456343`,
plus step 18's rationale and step 17's docblock fact (list below).
- **Census pin** in the existing
`packages/spec/src/migrations/migrations.test.ts` (registry integrity).
No new check script.
- `MIGRATIONS_BY_MAJOR[18].semantic` 186 → 211 entries at the census
base; after merging `main` (four sibling D3 entries landed meanwhile,
none with a D2 conversion) the generated region holds 215.

## The census (the card's main deliverable)

**Tree.** `objectstack-ai/objectstack` at `3cb84d084` (this branch's
fork point; it already contains objectstack-ai#20227's view-item retirement). Major-18
population there: **185** `retired-keys`, **146** `retired-defs`,
**186** `semantic` entry files, and **36** D2 conversions graduated into
step 18. (The card measured 181 / 130 / 175 at `d7c024133e`.)

**Grouping rule (ruling B's unit, held constant).** Where a D2
conversion exists, the conversion is the family: every retired key or
def it repairs belongs to it, and a D3 entry may cover more than one
conversion only where one judgment covers them (the pre-existing
`element-filter-and-form-node-refused` covers `element-filter-removed`
and `element-form-removed`). Every new entry here covers exactly one
conversion. Where no conversion exists, the records are grouped by the
D3 entry that names them.

**Method.**
1. Mechanical pass (scratch scripts, not committed): for each of the 36
conversions, the major-18 `semantic/` entry files that name its id as a
whole id (comment or field); for each retired key, the conversion its
own comment names, else the major-18 entries naming it as `cat/Def:key`,
`Def.path` / `Def:path`, or the def name plus the leaf key; for each
retired def, the entries naming the def.
2. Reading pass, where a string match cannot decide: (a) ownership: an
entry that names a conversion only in passing is not that family's entry
(this is how `metric-filters-removed` was classed missing although
`analytics-authorable-unknown-keys-refused` names it); (b) 14 records
matched by more than one entry, each placed by its own comment (for
example `kernel/PluginStartupResult:plugin`, which goes to
`startup-orchestrator-retired`); (c) 9 records whose comment names no
conversion but which belong to a D2 family
(`integration/DeclarativeConnectorEntry:connectionTimeoutMs` and
`:errorMapping`, the three error-mapping defs, the four responsive-shape
defs), plus `integration/Connector:connectionTimeoutMs`, whose comment
names two conversion ids and belongs to
`connector-connection-timeout-ms-removed` (it names the permission
conversion only as a comparison; round 1's Table 1 placed it wrongly,
corrected in patch round 1); (d) 5 theme sub-block defs, named by the
theme family's entry as its sub-blocks.

**Control.** The pairing sees a family that has its entry: 11 of the 36
conversions pair with a pre-existing entry, among them
`cube-join-sql-and-relationship-removed` with
`cube-join-sql-and-relationship-retired` (which the pin's own control
test also asserts), and the whole-id matcher refuses a prefix
(`record-chatter-position-vocabulary` is a prefix of its entry's own id
and matches only the entry's real citation). Evaluated at the base with
the pin's logic: **24** conversions named by no major-18 entry; the
reading pass adds `metric-filters-removed` for **25**. Evaluated at this
head: **0**.

**Result.** 331 records (185 keys + 146 defs) plus 36 conversions:

- **36 D2-backed families** covering 58 records: 11 had their D3 entry,
**25 had none**. Table 1.
- **273 D2-less records** in 68 groups: every one is named by an
existing D3 entry. Table 2. None missing, as expected: before ruling B,
a retirement with no conversion needed a D3 entry anyway.

The card's grep for 「lossless」 found the `tenancy.organizationField`
site and step 17. The census finds 25 major-18 families, most of them
with no 「lossless」 wording at all.

### Table 1 — D2-backed families (step 18 `conversionIds`, in order)

| # | D2 conversion (the family) | registered records | D3 entry at base
| D3 entry after |
|---|---|---|---|---|
| 1 | `field-malformed-scale-precision-removed` | none (value or
strict-key retirement, not in the two tables) |
`field-scale-precision-integer-refused` | unchanged |
| 2 | `record-chatter-position-vocabulary` | none (value or strict-key
retirement, not in the two tables) |
`record-chatter-position-vocabulary-converged` | unchanged |
| 3 | `element-input-target-variable-removed` |
`ui/ElementRecordPickerProps:targetVariable`,
`ui/ElementTextInputProps:targetVariable` | MISSING |
`element-input-target-variable-retired` (new) |
| 4 | `element-filter-removed` | `ui/ElementFilterProps:aria`,
`ui/ElementFilterProps:fields`, `ui/ElementFilterProps:layout`,
`ui/ElementFilterProps:object`, `ui/ElementFilterProps:showSearch`,
`ui/ElementFilterProps:targetVariable` |
`element-filter-and-form-node-refused` | unchanged |
| 5 | `element-form-removed` | `ui/ElementFormProps:aria`,
`ui/ElementFormProps:fields`, `ui/ElementFormProps:mode`,
`ui/ElementFormProps:object`, `ui/ElementFormProps:onSubmit`,
`ui/ElementFormProps:submitLabel` |
`element-filter-and-form-node-refused` | unchanged |
| 6 | `field-column-lists-canonicalized` | none (value or strict-key
retirement, not in the two tables) | MISSING |
`field-inline-and-related-list-columns-closed` (new) |
| 7 | `metric-filters-removed` | `data/Metric:filters` | MISSING (named
only in passing by `analytics-authorable-unknown-keys-refused`) |
`cube-metric-filters-retired` (new) |
| 8 | `cube-sub-day-granularities-removed` | none (value or strict-key
retirement, not in the two tables) |
`time-update-interval-sub-day-retired` | unchanged |
| 9 | `cube-join-sql-and-relationship-removed` |
`data/CubeJoin:relationship`, `data/CubeJoin:sql` |
`cube-join-sql-and-relationship-retired` | unchanged |
| 10 | `record-highlights-field-icon-removed` |
`ui/RecordHighlightsField:icon` | MISSING |
`record-highlights-field-icon-retired` (new) |
| 11 | `mapping-lookup-params-removed` | none (value or strict-key
retirement, not in the two tables) | MISSING |
`mapping-lookup-params-retired` (new) |
| 12 | `translation-component-submit-label-removed` | none (value or
strict-key retirement, not in the two tables) | MISSING |
`translation-component-submit-label-retired` (new) |
| 13 | `page-component-responsive-removed` |
`ui/PageComponent:responsive`, `ui/BreakpointColumnMap`,
`ui/BreakpointName`, `ui/BreakpointOrderMap`, `ui/ResponsiveConfig` |
MISSING | `page-component-responsive-retired` (new) |
| 14 | `object-grid-default-sort-removed` |
`ui/ObjectGridProps:defaultSort` | MISSING |
`object-grid-default-sort-retired` (new) |
| 15 | `object-kanban-quick-add-removed` |
`ui/ObjectKanbanProps:quickAdd` | MISSING |
`object-kanban-quick-add-retired` (new) |
| 16 | `permission-allow-restore-purge-removed` |
`security/EffectiveObjectPermission:allowPurge`,
`security/EffectiveObjectPermission:allowRestore`,
`security/ObjectPermission:allowPurge`,
`security/ObjectPermission:allowRestore` | MISSING |
`permission-restore-purge-bits-retired` (new) |
| 17 | `form-view-option-default-removed` | none (value or strict-key
retirement, not in the two tables) | MISSING |
`form-view-option-default-retired` (new) |
| 18 | `field-reference-to-alias` | none (value or strict-key
retirement, not in the two tables) | MISSING |
`field-reference-to-spelling-retired` (new) |
| 19 | `connector-error-mapping-removed` |
`integration/Connector:errorMapping`,
`integration/DeclarativeConnectorEntry:errorMapping`,
`integration/ConnectorErrorCategory`, `integration/ErrorMappingConfig`,
`integration/ErrorMappingRule` | MISSING |
`connector-error-mapping-retired` (new) |
| 20 | `connector-connection-timeout-ms-removed` |
`integration/Connector:connectionTimeoutMs`,
`integration/DeclarativeConnectorEntry:connectionTimeoutMs` |
`connector-provider-context-connection-timeout-ms-retired` | unchanged |
| 21 | `hook-timeout-to-timeout-ms` | none (value or strict-key
retirement, not in the two tables) | MISSING |
`hook-timeout-unit-in-key` (new) |
| 22 | `job-timeout-to-timeout-ms` | `system/Job:timeout` | MISSING |
`job-timeout-unit-in-key` (new) |
| 23 | `api-endpoint-cache-ttl-to-cache-ttl-seconds` |
`api/ApiEndpoint:cacheTtl` | MISSING |
`api-endpoint-cache-ttl-unit-in-key` (new) |
| 24 | `dashboard-refresh-interval-to-refresh-interval-seconds` |
`ui/Dashboard:refreshInterval` | MISSING |
`dashboard-refresh-interval-unit-in-key` (new) |
| 25 | `connector-health-and-trigger-durations-unit-in-key` |
`integration/CircuitBreakerConfig:monitoringWindow`,
`integration/ConnectorTrigger:interval` | MISSING |
`connector-resilience-durations-unit-in-key` (new) |
| 26 | `memory-persistence-auto-save-interval-to-ms` |
`data/AutoPersistenceConfig:autoSaveInterval`,
`data/FilePersistenceConfig:autoSaveInterval` | MISSING |
`memory-persistence-auto-save-interval-unit-in-key` (new) |
| 27 | `turso-config-timeout-to-timeout-ms` | `data/TursoConfig:timeout`
| MISSING | `turso-config-timeout-unit-in-key` (new) |
| 28 | `view-page-mount-removed` | `ui/ListView:pageName`,
`ui/ObjectListView:pageName` | MISSING | `list-view-page-mount-retired`
(new) |
| 29 | `list-view-sort-string-clause-to-array` | none (value or
strict-key retirement, not in the two tables) | MISSING |
`list-view-sort-string-clause-retired` (new) |
| 30 | `page-assigned-profiles-removed` | `ui/Page:assignedProfiles` |
`page-assigned-profiles-audience-to-permission-set` | unchanged |
| 31 | `chart-config-aria-removed` | `ui/ChartConfig:aria`,
`ui/ReportChart:aria` | MISSING | `chart-config-aria-retired` (new) |
| 32 | `dashboard-widget-chart-config-structure-removed` |
`ui/DashboardWidgetChartConfig:series`,
`ui/DashboardWidgetChartConfig:type`,
`ui/DashboardWidgetChartConfig:xAxis`,
`ui/DashboardWidgetChartConfig:yAxis` |
`dashboard-widget-chart-config-structure-refused` | unchanged |
| 33 | `translation-per-app-settings-removed` | none (value or
strict-key retirement, not in the two tables) |
`translation-per-app-settings-platform-only` | unchanged |
| 34 | `object-tenancy-organization-field-removed` |
`data/TenancyConfig:organizationField` | MISSING |
`object-tenancy-organization-field-retired` (new) |
| 35 | `page-component-filter-record-to-rule-array` | none (value or
strict-key retirement, not in the two tables) |
`element-data-source-and-object-block-filter-rule-array`,
`object-grid-default-filters-rule-array` | unchanged |
| 36 | `view-item-owner-hidden-removed` | `ui/ViewItemWire:hidden`,
`ui/ViewItemWire:owner`, `ui/ViewItem:hidden`, `ui/ViewItem:owner` |
MISSING | `view-item-owner-hidden-retired` (new) |

### Table 2 — D2-less records, grouped by the existing D3 entry that
names them

| D3 entry (existing) | records it names |
|---|---|
| `advanced-plugin-lifecycle-config-retired` |
`kernel/AdvancedPluginLifecycleConfig`, `kernel/GracefulDegradation`,
`kernel/PluginUpdateStrategy` |
| `ai-conversation-analytics-duration-unit-in-key` |
`ai/ConversationAnalytics:duration` |
| `api-error-retry-after-unit-in-key` |
`api/EnhancedApiError:retryAfter` |
| `api-runtime-config-durations-unit-in-key` |
`api/DataLoaderConfig:cacheTtl`, `api/RouteDefinition:timeout` |
| `automation-flow-list-route-retired` | `api/FlowSummary`,
`api/ListFlowsRequest`, `api/ListFlowsResponse` |
| `automation-runs-cursor-retired` | `api/ListRunsRequest:cursor` |
| `branded-identifier-schemas-retired` | `shared/AppName`,
`shared/FieldName`, `shared/FlowName`, `shared/ObjectName`,
`shared/RoleName`, `shared/ViewName` |
| `change-management-duration-keys-retired` |
`system/ChangeImpact:downtime.durationMinutes`,
`system/ChangeRequest:implementation.steps.estimatedMinutes`,
`system/RollbackPlan:steps.estimatedMinutes` |
| `change-management-family-retired` | `system/ChangeImpact`,
`system/ChangePriority`, `system/ChangeRequest`, `system/ChangeStatus`,
`system/ChangeType`, `system/RollbackPlan` |
| `cli-command-contribution-retired` | `kernel/CLICommandContribution` |
| `cloud-subpath-retired` | 62 records, all `cloud/` defs |
| `data-file-value-duration-unit-in-key` | `data/FileValue:duration` |
| `data-nosql-query-options-timeout-unit-in-key` |
`data/NoSQLQueryOptions:timeout` |
| `device-request-response-interval-unit-in-key` |
`api/DeviceRequestResponse:interval` |
| `driver-options-timeout-to-timeout-ms` | `data/DriverOptions:timeout`
|
| `epoch-instant-keys-renamed` | `api/SimplePresenceState:lastSeen`,
`api/WebSocketEvent:timestamp`, `kernel/HealthStatus:timestamp`,
`kernel/KernelContext:startTime`,
`kernel/TenantRuntimeContext:startTime` |
| `esignature-config-deadline-keys-retired` |
`data/ESignatureConfig:expirationDays`,
`data/ESignatureConfig:reminderDays` |
| `event-name-schema-retired` | `shared/EventName` |
| `export-job-family-retired` | 13 records, all `api/`, `automation/`
defs |
| `hot-reload-inert-state-strategies-retired` |
`kernel/DistributedStateConfig` |
| `hot-reload-watch-placeholder-retired` |
`kernel/HotReloadConfig:watchPatterns` |
| `identity-api-key-schema-retired` | `identity/ApiKey` |
| `incident-response-deadline-keys-retired` |
`system/IncidentNotificationMatrix:escalationTimeoutMinutes`,
`system/IncidentNotificationRule:regulatorDeadlineHours`,
`system/IncidentNotificationRule:withinMinutes`,
`system/IncidentResponsePhase:targetHours`,
`system/IncidentResponsePolicy:retentionDays`,
`system/IncidentResponsePolicy:triageDeadlineHours` |
| `incident-response-family-retired` | `system/Incident`,
`system/IncidentCategory`, `system/IncidentNotificationMatrix`,
`system/IncidentNotificationRule`, `system/IncidentResponsePhase`,
`system/IncidentResponsePolicy`, `system/IncidentSeverity`,
`system/IncidentStatus` |
| `kernel-compatibility-matrix-estimated-migration-time-unit-in-key` |
`kernel/CompatibilityMatrixEntry:estimatedMigrationTime` |
| `kernel-context-preview-mode-retired` |
`kernel/KernelContext:previewMode`, `kernel/PreviewModeConfig`,
`kernel/TenantRuntimeContext:previewMode` |
| `kernel-event-bus-retention-unit-in-key` |
`kernel/EventPersistence:retention`,
`kernel/EventSourcingConfig:retention` |
| `kernel-health-check-and-hot-reload-durations-unit-in-key` |
`kernel/HotReloadConfig:debounceDelay`,
`kernel/PluginHealthCheck:interval`, `kernel/PluginHealthCheck:timeout`
|
| `kernel-package-lifecycle-durations-unit-in-key` |
`kernel/MultiVersionSupport:rollout.duration`,
`kernel/PackageDependencyResolutionResult:resolvedIn`,
`kernel/UpgradePlan:estimatedDuration` |
| `kernel-plugin-health-report-durations-unit-in-key` |
`kernel/PluginHealthReport:metrics.responseTime`,
`kernel/PluginHealthReport:metrics.uptime` |
| `kernel-plugin-security-durations-unit-in-key` |
`kernel/KernelSecurityPolicy:auditLog.retention`,
`kernel/KernelSecurityPolicy:authentication.tokenExpiration`,
`kernel/PluginSecurityManifest:vulnerabilityDisclosure.responseTime` |
| `kernel-runtime-config-timeout-unit-in-key` |
`kernel/RuntimeConfig:resourceLimits.timeout`,
`kernel/SandboxConfig:process.timeout` |
| `kernel-startup-orchestrator-durations-unit-in-key` |
`kernel/PluginStartupResult:duration`, `kernel/StartupOptions:timeout`,
`kernel/StartupOrchestrationResult:totalDuration` |
| `list-view-navigation-view-retired` | `ui/NavigationConfig:view` |
| `logging-durations-unit-in-key` |
`system/HttpDestinationConfig:batch.flushInterval`,
`system/HttpDestinationConfig:retry.initialDelay`,
`system/HttpDestinationConfig:timeout`,
`system/LoggingConfig:buffer.flushInterval` |
| `metadata-changed-event-payload-retired` |
`kernel/MetadataChangeOperation`, `kernel/MetadataChangedEventPayload` |
| `metadata-customization-protocol-retired` | 13 records, all `api/`,
`kernel/` defs |
| `metadata-manager-config-cache-ttl-unit-in-key` |
`kernel/MetadataManagerConfig:cache.ttl` |
| `metadata-manager-config-inert-cache-keys-retired` |
`kernel/MetadataManagerConfig:cache.enabled`,
`kernel/MetadataManagerConfig:cache.maxSize`,
`kernel/MetadataManagerConfig:cache.ttlSeconds` |
| `metadata-plugin-additional-types-retired` |
`kernel/MetadataPluginConfig:additionalTypes` |
| `package-rollback-response-retired` | `api/PackageRollbackResponse` |
| `packages-list-pagination-retired` |
`api/ListInstalledPackagesRequest:cursor`,
`api/ListInstalledPackagesRequest:limit` |
| `plugin-auto-restart-never-reinitialised` |
`kernel/PluginHealthCheck:autoRestart`,
`kernel/PluginHealthCheck:maxRestartAttempts`,
`kernel/PluginHealthCheck:restartBackoff` |
| `plugin-manifest-contributes-dead-members-retired` |
`kernel/Manifest:contributes.actions`,
`kernel/Manifest:contributes.commands`,
`kernel/Manifest:contributes.drivers`,
`kernel/Manifest:contributes.events`,
`kernel/Manifest:contributes.fieldTypes`,
`kernel/Manifest:contributes.functions`,
`kernel/Manifest:contributes.menus`,
`kernel/Manifest:contributes.themes`,
`kernel/Manifest:contributes.translations` |
| `plugin-manifest-contributes-routes-retired` |
`kernel/Manifest:contributes.routes` |
| `plugin-manifest-dead-containers-retired` |
`kernel/Manifest:capabilities`, `kernel/Manifest:configuration`,
`kernel/Manifest:extensions` |
| `plugin-manifest-kind-globs-retired` |
`kernel/Manifest:contributes.kinds.globs` |
| `plugin-security-scan-result-surface-retired` |
`kernel/KernelSecurityScanResult`, `kernel/KernelSecurityVulnerability`,
`kernel/PluginQualityMetrics:securityScan`,
`kernel/PluginSecurityManifest:scanResults`,
`kernel/PluginSecurityManifest:vulnerabilities` |
| `rest-api-endpoint-handler-status-retired` | `api/HandlerStatus`,
`api/RestApiEndpoint:handlerStatus`, `api/RouteCoverageEntry`,
`api/RouteCoverageReport` |
| `rest-api-plugin-durations-unit-in-key` |
`api/RestApiEndpoint:cacheTtl`, `api/RestApiEndpoint:timeout`,
`api/RestApiPluginConfig:performance.defaultCacheTtl` |
| `rest-server-config-dead-keys-retired` | 11 records, all `api/` defs |
| `session-user-language-retired` | `api/SessionUser:language` |
| `stack-themes-carrier-retired` | `ui/BorderRadius`, `ui/ColorPalette`,
`ui/Shadow`, `ui/Theme`, `ui/ThemeMode`, `ui/Typography` |
| `startup-orchestrator-retired` | `kernel/HealthStatus`,
`kernel/PluginStartupResult:health`,
`kernel/PluginStartupResult:plugin`,
`kernel/PluginStartupResult:startTime`, `kernel/StartupOptions`,
`kernel/StartupOrchestrationResult` |
| `system-cache-durations-unit-in-key` |
`system/CacheAvalanchePrevention:circuitBreaker.resetTimeout`,
`system/CacheTier:ttl` |
| `system-collaboration-durations-unit-in-key` |
`system/CollaborationSessionConfig:idleTimeout`,
`system/CollaborationSessionConfig:snapshot.interval` |
| `system-failover-health-check-interval-unit-in-key` |
`system/FailoverConfig:healthCheckInterval` |
| `system-metrics-jsdoc-durations-unit-in-key` |
`system/MetricDefinition:summary.maxAge`,
`system/MetricExportConfig:interval`,
`system/MetricsConfig:collectionInterval`,
`system/MetricsConfig:retention.period`,
`system/ServiceLevelObjective:errorBudget.burnRateWindows.window` |
| `system-metrics-window-durations-unit-in-key` |
`system/MetricAggregationConfig:window.size`,
`system/ServiceLevelIndicator:window.size`,
`system/ServiceLevelObjective:period.duration` |
| `system-object-storage-durations-unit-in-key` |
`system/AccessControlConfig:maxAge`, `system/StorageConnection:timeout`
|
| `system-registry-config-durations-unit-in-key` |
`system/RegistryConfig:cache.ttl`,
`system/RegistryUpstream:syncInterval`,
`system/RegistryUpstream:timeout` |
| `system-tracing-otel-exporter-durations-unit-in-key` |
`system/OpenTelemetryCompatibility:exporter.batch.exportTimeout`,
`system/OpenTelemetryCompatibility:exporter.batch.scheduledDelay`,
`system/OpenTelemetryCompatibility:exporter.timeout`,
`system/TracingConfig:performance.exportInterval` |
| `system-tracing-span-duration-unit-in-key` | `system/Span:duration` |
| `system-worker-queue-rate-limit-duration-unit-in-key` |
`system/QueueConfig:rateLimit.duration` |
| `tenant-schema-cache-ttl-unit-in-key` |
`system/SchemaLevelIsolationStrategy:performance.schemaCacheTTL` |
| `training-deadline-keys-retired` |
`system/TrainingCourse:durationMinutes`,
`system/TrainingCourse:validityDays`,
`system/TrainingPlan:gracePeriodDays`,
`system/TrainingPlan:recertificationIntervalDays`,
`system/TrainingPlan:reminderDaysBefore` |
| `training-family-retired` | `system/TrainingCategory`,
`system/TrainingCompletionStatus`, `system/TrainingCourse`,
`system/TrainingPlan`, `system/TrainingRecord` |
| `websocket-durations-unit-in-key` |
`api/WebSocketConfig:pingInterval`,
`api/WebSocketConfig:reconnectInterval`, `api/WebSocketConfig:timeout`,
`api/WebSocketServerConfig:heartbeatInterval` |

## Prose corrected (the single-entry sites of `5854110917` /
`5854456343`, and the rationale sentences)

| site (at this head) | was | now |
|---|---|---|
| `packages/spec/src/migrations/registry.ts:78–86` (step 17 docblock,
fact correction only) | 「Mechanical, and mechanical only … there is no
semantic residue and the `semantic` list is deliberately empty」 | the
three renames replay losslessly as D2; they carry no D3 entry because
step 17 shipped before the rule and was not back-filled; the `semantic`
list is NOT empty. ⛔ No step-17 entry added. |
| `packages/spec/src/migrations/registry.ts:5256` (step 18 rationale,
`tenancy.organizationField`) | 「The conversion is a lossless delete and
there is no semantic residue」 | a lossless delete still leaves the
author a judgment, carried by
`object-tenancy-organization-field-retired` |
| `packages/spec/src/conversions/registry.ts:3460`
(`datasource-driver-mongo-to-mongodb`, protocol 17) | 「Why D2 and not
D3」 | 「Why the data repair is D2」, plus: losslessness does not decide
whether a family owes D3; this one is protocol 17 and has none |
| `packages/spec/src/conversions/registry.ts:9312`
(`api-endpoint-cache-ttl-to-cache-ttl-seconds`) | 「gets a conversion
rather than a semantic entry」 | 「also gets a conversion」, and names its
D3 entry |
| `packages/spec/src/conversions/registry.ts:9804`
(`list-view-sort-string-clause-to-array`) | 「which is why this is a D2
conversion rather than a semantic TODO」 | the data repair is D2; the
family's D3 entry carries the clauses the rewrite leaves alone |
|
`packages/spec/src/migrations/entries/retired-keys/18.api__ApiEndpoint__cacheTtl.ts:11–19`
| 「a D2 CONVERSION rather than a semantic entry」 | also a D2 conversion,
and names the D3 entry |
|
`packages/spec/src/migrations/entries/semantic/18.metadata-endpoints-switch-radius-repartitioned.ts:11–13`
| 「exactly the residue D2 cannot express, which is why this is a
semantic entry」 | that residue is why there is no D2 at all; the D3
entry is owed either way |
| `packages/spec/scripts/build-migration-registry.ts:276` | 「a major
whose semantic residue is genuinely nil (protocol 14)」 | an empty region
is a real state (a freshly opened step, or protocol 14's, which predated
the rule) |

⛔ Not touched: the governed texts (ADR-0087,
`.claude/skills/spec-property-retirement/SKILL.md` §3), which are
objectstack-ai#20188's.

## The census pin

**Where:** `packages/spec/src/migrations/migrations.test.ts` › `registry
integrity`: `from protocol 18 on, every graduated D2 conversion is named
by a D3 entry of its own step (ruling B)`, plus a control test. It reads
the major's `entries/semantic/` files (comment and literal) inside its
own package; `check:migration-registry` already proves those files and
the generated region are one set.

**What it asserts:** for every step whose `toMajor` is 18 or later,
every id in `conversionIds` appears as a whole id in at least one
`semantic/` entry of that major. A new major-18 (or later) retirement
that lands a D2 conversion with no D3 entry naming it goes red, naming
the conversion.

**What it cannot see**, stated so a green run is not over-read: (1)
whether the naming entry is that family's OWN (a passing mention
satisfies it; the census judged ownership by reading); (2) a family
retired with no conversion at all (no machine-readable link joins a
retired key or def to its D3 entry; the census paired those by reading,
and found none missing). Protocol 17 is outside the pin by design:
measured with the same logic, 53 of its 57 graduated conversions are
named by no step-17 entry, and step-17 backfill is out of scope (triage
`5854164872`).

**Reverse verification (one-shot, no permanent test file).** At
`c8656ad35`, with the entries committed: deleted
`18.object-tenancy-organization-field-retired.ts` (absence confirmed on
disk before the run), ran the pin: `× from protocol 18 on …` with `+
"protocol 18: object-tenancy-organization-field-removed"`, `Tests 1
failed | 140 skipped`. Restored with `git checkout HEAD -- PATH` (that
path) inside a `trap … EXIT INT TERM`: blob `2cf3007d9fff` equals
HEAD's, `git diff HEAD` empty. Direction observed: red, the expected
one. No build involved: the test imports `src/` and reads the entry
files directly.

## Patch round 1 (contract review `5857834457`: FAIL at `3197fce29`)

**Blocking: fixed.** `Lint & Repo Gates` step 189
(`check-issue-citations.mjs`, judging pass) was red. Four bare citations
this PR added answer 404 on the board: objectstack-ai#10329, objectstack-ai#10926, objectstack-ai#12868 and
objectstack-ai#14676. Each was in an entry's leading comment, and again in the
regenerated region. `--probe-cause` classes all four as **deleted** (the
web endpoint also answers 404, so none was transferred), so none of them
is a reference to another repository to qualify. Each comment now
anchors to the commit in this repository's history that retired the
family, and says in words what that commit decided. That is the
precedent of commit `66e266c93` (ruling C+D on objectstack-ai#19123). Every sha is an
ancestor of `origin/main`:

| entry | was | now anchored to |
|---|---|---|
| `mapping-lookup-params-retired` | objectstack-ai#10329 | commit `15d58dbf1` (the
import path never read the four lookup steering params) |
| `translation-component-submit-label-retired` | objectstack-ai#10926 | commit
`d173125fb` (the copy key left with its only declarer, `element:form`) |
| `form-view-option-default-retired` | objectstack-ai#12868 | commit `c459da6bc` (the
ruled narrowing: the form-view face drops per-option `default`, the
object-field face keeps it enforced) |
| `connector-error-mapping-retired` | objectstack-ai#14676 | commit `13c48c2a5`
(eleven inert keys, one spelled like the live `userMessage` channel) |

Only the comments changed; no string an author is shown moves. The
region was regenerated with `gen:migration-registry`. The round-1
report's `pnpm check:issue-citations :: exit 0` was the package script,
which runs only the `--self-test`. The judging pass CI runs was exit 2
at `3197fce29` (8 findings = 4 numbers × 2 sites) and is exit 0 now
(below).

**Pin message.** The census pin's assertion now names the unnamed
`protocol N: conversion-id` pairs and the remedy: add a D3 `semantic`
entry of that step whose text names the conversion id as a whole word.
Its logic and scope are unchanged. Shown firing at `21418c4d2` with one
entry removed (trap-guarded restore, blob equal to HEAD's, `git diff
HEAD` empty): `AssertionError: graduated D2 conversion(s) named by no D3
entry of their own step: protocol 18:
object-tenancy-organization-field-removed. Remedy: add a D3 semantic
entry of that step …`, `Tests 1 failed | 140 passed`.

**Body.** Table 1: `integration/Connector:connectionTimeoutMs` moved
from row 16 to row 20. Its own comment names
`connector-connection-timeout-ms-removed`; the permission id appears
there only as a comparison. The code was already right.

## Sibling PRs

- **PR objectstack-ai#20238** (objectstack-ai#20161) has since LANDED as `6a6a17b62`, with the D2
conversion `report-joined-chart-removed` and
`18.ui-report-joined-chart-retired.ts`, which names that id. The union
of this head with `main` at `6a6a17b62` is clean and passes the pin (37
pairs, 0 unnamed; delta review `5859315908`).
- `main` was merged three times with `os-regen-merge.sh`, and never by
hand in a generated region: at `a70cd62e5` (objectstack-ai#20223 and objectstack-ai#20245), at
`21418c4d2` (`cel-predicate-one-value-comparand-refused` and
`filter-query-face-comparands-refused-at-save`) and at `a930cacea`
(step-17 rationale prose from objectstack-ai#20268). Each is D3-only or prose, with no
new step-18 conversion. Regeneration produced a commit only after the
first merge (`3197fce29`) and changed nothing after the other two. Every
sibling entry id was verified present.

## Verification (head `a930cacea`)

- `pnpm check:issue-citations && node
scripts/check-issue-citations.mjs`, exactly as CI runs it (base
`origin/main`): **exit 0**, 112 citations across 29 files: 106 resolve,
6 cross-repo unjudged, 0 findings. At `3197fce29` the same command
exited 2.
- `pnpm --filter @objectstack/spec build` under the verify lock: ok.
`check:generated`: all 15 generated artifacts up to date.
`check:migration-registry`: current (292 semantic, 214 retired-key, 199
retired-def). `spec-changes.json` and `docs/protocol-upgrade-guide.md`
do not move: they project up to the current protocol major, and step 18
is beyond it.
- `pnpm --filter @objectstack/spec exec vitest run --project local`:
**552 files, 16257 passed, 1 todo**. The `src/migrations/` directory
alone: 3 files, 151 passed.
- `pnpm --filter @objectstack/spec typecheck` (tsc, scripts, test layer)
at `21418c4d2`, the head before the last merge, which brought only
another PR's prose into this diff's files: exit 0, test-typecheck debt
unchanged (53 files / 255 errors / 142 signatures).
- `node scripts/pm/dispatch-gates.mjs --commands` (no paths) at
`a930cacea`, every command run and its exit recorded, reconciled with
`--ran`: **89 derived, 87 run (all exit 0), 2 NOT MEASURED**.
`check:dual-build-cjs-loads` and `check:type-check-debt` exited 3
(PREREQUISITE NOT MET: the full 86-package workspace build does not fit
the foreground cap on this shared box). CI runs both.
`check:pm-dispatch-gates` finished this time: exit 0, in 907.6 s.
- ESLint, narrowed and proven: all 31 changed `.ts` files,
`--no-inline-config --format json`: 0 errors, 0 warnings, none reported
ignored. `eslint.config.mjs` enables no type-aware linting (its own
statement at `eslint.config.mjs:326–328`), so this diff cannot move any
untouched file's verdict.
- Changeset: `@objectstack/spec` `patch`. The published registry text
changes; no accept set moves.

## Acceptance notes (observed, not filed)

- `registry.ts:108` (released step-17 text) still says the sharing-rule
`full` conversion 「leaves no semantic residue」: triage scoped step-17
backfill out, so it is left as is.
- New entries keep tracker numbers in their `//` comments only, never in
the strings an author is shown (AGENTS.md runtime-strings rule). Several
older entries do cite numbers in `reason`; not touched.
- `dashboard-refresh-interval-unit-in-key` states the console renderer's
release lag as a verification step, not as a present fact: this
container has no objectui checkout at the pin to measure it.
- `main` moved after the last merge (`a930cacea`). objectstack-ai#20285 (`2aa25efb4`,
prose in five semantic entries) and objectstack-ai#20238 (`6a6a17b62`, a new step-18
conversion with its entry) landed under `migrations/` and
`conversions/`. The delta review merged this head onto `6a6a17b62`:
clean, with all six regions still mirrored. The queue verifies the
merged generation. (Corrected by the seat at 2026-09-27T19:57Z; the
earlier wording said nothing under those paths had moved.)

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…ocally, off the checker's own local-env declaration (objectstack-ai#20314)

Fixes objectstack-ai#20278

Clause-②: no

## What this changes

`dispatch-gates --commands` now lists the diff-scoped issue-citation
verdict, bare `node scripts/check-issue-citations.mjs`. That is the run
`Lint & Repo Gates` blocks on, and before this change the tool printed
it as `⊘ NOT MEASURED`, so a dev's local sweep could reconcile clean
while CI went red (measured on objectstack-ai#20268's CI run). The census and every
other invocation of the same script keep exactly the classification they
had.

This implements option A of the seat ruling on the card (ruling comment
5858896081): two files, one declaration and one reader.

## Why the family was NOT MEASURED

The `&&` in `pnpm check:issue-citations && node
scripts/check-issue-citations.mjs` was already split into two families.
The cause is the **env carrier**: the lint.yml step passes
`GITHUB_TOKEN: ${{ github.token }}` and `OS_GATE_MERGE_GROUP_BASE_SHA:
${{ github.event.merge_group.base_sha }}` through `env:`.
`workflowEnvValues` treated both as values with none outside CI, so
`discoverFamilies` set `notRunnable` and `commandsFor` dropped the
family. The script needs neither on a pull-request run:

- The merge-group base renders empty on `pull_request`, and the checker
then uses `merge-base origin/main HEAD`.
- The token is used when present, and the diff mode reads the public
board with one probe per added number.

A rule about the variables alone cannot fix this. Dropping the job token
and the merge-group base generically was measured to also admit the same
script's `--census` (a report-only enumeration of the whole board) and
`check-required-contexts.mjs --verify-required-set` (exit 2 here). The
fact belongs to the program and to the mode, so the program declares it.

## The change (2 files)

1. **`scripts/check-issue-citations.mjs`**: one whole-line comment in
the existing `dispatch-gates:` marker idiom: `// dispatch-gates:
local-env GITHUB_TOKEN OS_GATE_MERGE_GROUP_BASE_SHA -- REASON`. The
reason states the file's own facts. It covers the bare invocation only;
the census needs the token and is not declared.
2. **`scripts/pm/dispatch-gates.mjs`**:
- `local-env` joins `PATH_LIST_MARKER_KEYS`, the LIST-then-reason
grammar. It gets the wholeness reading, the lookalike probe and the
census by construction. There is no new grammar copy, no table and no
gate.
- `declaredLocalEnv` reads the declaration. It refuses a cut reason and
any token that is not an env name.
- `localEnvRefusal` grades the declaration against the tree's workflows.
It throws, so the CLI answers `derivation failed` (exit 2), when either:
- a declared name is not passed by every step running the bare
invocation as an `env:` workflow value;
     - no workflow runs the bare invocation.
- `localEnvAdmitted` spends the declaration on the bare key alone.
`--census`, `--self-test`, `check:*` keys and every other argv admit
nothing.
- `workflowEnvValues` gains one per-NAME limb: only the declared names
leave the list, so an undeclared name still keeps the family out.
- The matched row carries `localEnv`, in `--json` and in a same-line
note in the human block, so no rendering lists the command without
saying why.
- The docblock states what the reader does NOT do: guess; admit a name
the workflow does not pass; admit an argv no workflow runs; touch any
other invocation of the same script; repair an argv value.
   - Self-test:
- Direction pins on the lint.yml step text: the bare command is in the
runnable union and not in the not-runnable set.
     - The census invocation stays not-runnable on `GITHUB_TOKEN`.
- Controls: an undeclared script keeps the old reading, and a partial
declaration keeps the undeclared name.
- Refusals for a stale name, a name only some steps pass, and a missing
bare run.
     - Live in-process pins on this tree for the `rls.zod.ts` specimen.
- The key roster and marker census moved from six to seven keys and from
seven to eight declarations, each row named.
     - No absolute counts.

`package.json` is untouched. Renaming `check:issue-citations` was
optional in the triage notes and would not make the diff-scoped run
runnable: the derivation keys on what lint.yml runs, not on the alias
name.

## Evidence

**`--commands` for `packages/spec/src/security/rls.zod.ts`.** Before is
base `3f86dc52`, after is head `15ab041f`. Stdout gains exactly one
line; nothing else on stdout moves.

```text
before  dispatch-gates --commands: 71 command(s) — 42 pnpm, 29 direct node (65 matched by path, ...)
before    + 7 famil(ies) matched by path take a VALUE FROM THE WORKFLOW and are NOT above ...
before        ⊘ NOT MEASURED — scripts/check-issue-citations.mjs --census
before        ⊘ NOT MEASURED — scripts/check-issue-citations.mjs
after   stdout line 12: node scripts/check-issue-citations.mjs
after   dispatch-gates --commands: 72 command(s) — 42 pnpm, 30 direct node (66 matched by path, ...)
after     + 6 famil(ies) matched by path take a VALUE FROM THE WORKFLOW and are NOT above ...
after         ⊘ NOT MEASURED — scripts/check-issue-citations.mjs --census
```

The human block now renders the row as `- node
scripts/check-issue-citations.mjs [lint.yml] matched via
packages/spec/src/security/rls.zod.ts ⇢ gate source 'packages/**' · runs
here without GITHUB_TOKEN, OS_GATE_MERGE_GROUP_BASE_SHA: its script
declares local-env for this bare invocation, and CI passes them through
the step's env:`.

**The command it now lists answers the question here.** Measured in a
detached probe worktree at `3f86dc52` with an uncommitted edit, since
restored and removed:

- `objectstack-ai#16608` re-added on `rls.zod.ts:426`: exit 2, `[allocated-but-absent]
packages/spec/src/security/rls.zod.ts:426 objectstack-ai#16608`, the finding from
objectstack-ai#20268's CI run.
- Control with a resolving number: exit 0.
- Control with `GITHUB_TOKEN`/`GH_TOKEN` unset: exit 0.

**Self-test, through the verify lock.**
- `pnpm check:pm-dispatch-gates` at `15ab041f`: `os-verify-lock: VERDICT
command-exit 0 · held the lock 975s (16m15s) · waited 0s · ⚠ SHARED-BOX
SECONDS`. Its log reads `✓ dispatch-gates self-test: 1976 cases pass.`
and `✓ check:pm-dispatch-gates --self-test: the exit contract holds in
all three directions.`
- An earlier `node scripts/pm/dispatch-gates.mjs --self-test` at
`23973549` also answered `VERDICT command-exit 0`, 1976 cases.

**Ablations.** Both legs used `scripts/ablation-replace.mjs`, which
checks the anchor hit and that the blob changed, then restores with blob
== HEAD and an empty `git diff HEAD`.

- Leg 1, declaration disabled (`// dispatch-gates: local-env
GITHUB_TOKEN` → `// ablated-marker: local-env GITHUB_TOKEN`, blob
`49bf630d` → `64ab1b63`): `--commands` for `rls.zod.ts` loses `node
scripts/check-issue-citations.mjs` from stdout (0 hits), and stderr
prints `⊘ NOT MEASURED — scripts/check-issue-citations.mjs` again.
Restored: blob == HEAD `49bf630d`.
- Leg 2, stale name planted (`... OS_GATE_MERGE_GROUP_BASE_SHA PR_BODY
--`): the CLI exits 2 with `derivation failed — dispatch-gates:
scripts/check-issue-citations.mjs:204 declares local-env PR_BODY, which
the step(s) running node scripts/check-issue-citations.mjs do not all
pass as a workflow value through env: ...`. Restored: blob == HEAD.

**Derived gate sweep for this diff.** Ran `node
scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack
--commands` with no paths, 2 paths against merge base `3f86dc52`. It
derived 33 families, and each was run with its exit code captured before
any pipe. Reconciled at head `15ab041f`:

```text
Run reconciliation — 33 derived, 33 run, 0 NOT-MEASURED, 0 UNRUN.
✓ dispatch-gates --ran: 33 derived famil(ies) accounted for — 33 run, 0 NOT-MEASURED (a DERIVED zero — all 33 recorded an exit code and none of them is 3).
```

All 33 exit 0 at `15ab041f`, among them `node
scripts/check-issue-citations.mjs`, `pnpm check:issue-citations`, `pnpm
check:pm-dispatch-gates`, `pnpm check:watch-hint-literal`, `pnpm
check:nul-bytes` and `pnpm check:declared-population-live`. The first
sweep, at `23973549`, was red on one family: `pnpm
check:watch-hint-literal` exit 1, `every live declaration is a literal
-- scripts/pm/dispatch-gates.mjs:ROOT_DIR_WATCH_HINTS`. A self-test
fixture string used that rostered name. `15ab041f` renames the fixture
constant, and the whole sweep was then re-run on that head. `pnpm
check:pm-skill-id-lint` (named by the dispatch) also ran and exited 0:
`34 file(s) clean`.

**Lint, a narrowed run.** `eslint --no-inline-config --format json` on
the two changed files at `15ab041f` exited 0. The JSON reports 2 files,
0 errors and 0 warnings. The config `--print-config` reports for
`scripts/pm/dispatch-gates.mjs` carries no `parserOptions.project` and
no `projectService`, so linting is not type-aware and this diff cannot
move the verdict on any untouched file. Repo-wide `pnpm lint` is CI's.

**Changeset: `skip-changeset`.**
- Both files are in the repo-root package, which is `"private": true`
with no `files[]`.
- `git grep` for `declaredLocalEnv`, `localEnvRefusal` and the marker
over `packages/**` and `apps/**`: 0 hits.
- Positive control: `defineStack` hits `packages/spec/src/stack.zod.ts`,
which `@objectstack/spec`'s `files[]` ships (`src/**/*.zod.ts`).

**Line budget.**
- `scripts/pm/dispatch-gates.mjs`: 29507 → 29866 lines (1,746,809 →
1,768,400 bytes).
- `scripts/check-issue-citations.mjs`: 1213 → 1215 lines (65,982 →
66,904 bytes).

No gate added, no ceiling moved, no table added.

## Acceptance notes

- The census row, `node scripts/check-issue-citations.mjs --census`, is
still printed NOT MEASURED on every card in the checker's `packages/**`
population. Its only workflow, `half-state-patrol.yml`, is path-filtered
to the checker and patrol files, and the step is report-only. The ruling
records this as a note, and it is unchanged here.
- `check-required-contexts.mjs --verify-required-set` refuses here for a
proxy-route reason (exit 2, HTTP 401), while dispatch-gates names `env
GITHUB_TOKEN`. The ruling records this as a note, and it is unchanged
here.
- The census row's existing sibling note ("a DIFFERENT invocation of the
same script …") now names the bare diff run as that sibling. The wording
holds as written: a different argv asks another question.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…ADR-0066 and the data skill (objectstack-ai#20298)

Fixes objectstack-ai#20275

Clause-②: no

Two governed texts said the RLS write check wrong. ADR-0066's
combination rule (item 3) called row policies "OR-combined (any matching
policy admits the row)" for every operation; the published data skill's
RLS section called `using` the read filter and `check` the write filter,
with no stand-in rule, no per-row statement and no refusal rule. Both
now say what `main` enforces, in the wording the schema already carries.
objectstack-ai#20268 landed the non-governed half (the `rls.zod.ts` overview and
describe texts, the docs pages); this PR is the governed half and waits
on the maintainer's hand (Tier H: `docs/adr/**` and `skills/**`). No
code change.

Provenance of the three facts the skill now states: every-row judging of
inserts and updates landed in objectstack-ai#19988 and objectstack-ai#20012; the refusal of a
non-blank `check` on a `select` / `delete` policy in objectstack-ai#20167; the
per-operation default and the default-posture wording in objectstack-ai#20268.

## What changed

### `docs/adr/0066-unified-authorization-model.md` — item 3 of
"Precedence / combination semantics"

One sentence. It now reads: on a read, the applicable policies' `using`
predicates OR-combine (any matching policy admits the row); on an insert
or update, the check is chosen once per operation across the applicable
policies — the declared `check` predicates when any declares one, else
each applicable policy's `using` standing in — and the chosen predicates
OR-combine over every row written. The tenant-isolation clause and the
superuser-bypass sentence are unchanged; Status, headings and the other
items are untouched.

The enforcing code is cited as symbol anchors, so
`check:adr-symbol-anchors` holds them:

-
`packages/plugins/plugin-security/src/rls-compiler.ts#RLSCompiler.compileFilter`
— read side: OR-combines the applicable policies' `using` (its docblock:
"Multiple policies for the same object/operation are OR-combined").
-
`packages/plugins/plugin-security/src/security-plugin.ts#writeCheckPolicies`
— write side: takes the applicable policies that declare `check`; when
none does, the ones that declare `using` (the platform ownership floor
kept exactly when the pre-image gate kept it, `keepOwnershipFloor`).
`computeWriteCheckFilter` then hands that set to `compileFilter` with
the `check` clause, which OR-combines.

### `skills/objectstack-data/rules/security.md` — the "Row-Level
Security (RLS)" paragraph and its example comments

The paragraph now states, in this order:

1. `using` admits rows — what a `select` policy lets the caller read,
and the existing rows an `update`/`delete` policy lets it change or
remove; on a read the applicable `using` OR-combine, then AND into the
query.
2. `check` is judged on every row an `insert`/`update` writes (array
inserts and `multi: true` included; one failing row refuses the write),
chosen per operation: when any applicable policy declares `check`, only
those decide (OR-combined); else each applicable `using` stands in.
3. A non-blank `check` on a `select`/`delete` policy is refused.
4. The default posture, in the schema overview's words ("Default deny,
among the policies that apply … when none applies the policies restrict
nothing (the tenant wall still applies)"), plus the one clause the
review of the non-governed half named: an `update`/`delete` target with
no write-class `using` is bounded by the caller's `select` policies (the
by-id pre-image gate derives its scope from the caller's SELECT
narrowing when no write-class `using` applies; not for `insert`, not
under the read-side superuser bypass).

The example's two comments (`// read scope` / `// write scope`) now read
`// rows readable / targetable` and `// every row written`.

Wording follows the `RowLevelSecurityPolicySchema.using` / `.check`
describe texts and the `rls.zod.ts` overview as landed in `3f86dc52`; no
second phrasing of the same fact was introduced. No issue or PR number
appears in the skill text (`check:doc-authoring` refuses one under
`skills/`).

### Paying the token ceiling

`check:skills-token-ratchet` holds `security.md` at 2543 tokens with
headroom 0. The RLS paragraph grew by 687 bytes and the two comments by
22; the difference is paid in the same file by removing sentences the
file already states elsewhere — nothing moved to another file, the
ceiling is untouched:

- the `permissions`-vs-`permissionSets` bullet no longer repeats the
code comment two lines above it (the refusal text, the
`ObjectStackDefinitionSchema` source and "never a silent drop" stay);
- the `permission_set_id` warning no longer says "record id" twice;
- the RLS source line cites `rls.zod.ts` once (policy shape, grammar,
`check` composition) instead of `permission.zod.ts` again (already cited
under RBAC);
- the owner-scoping bullet, the `requiredPermissions` paragraph (its
enforcer was already named under `maskingRule`), the platform-global
paragraph and its blockquote lose filler words, no facts.

## Readings

Line/token budget (tokens = `ceil(utf8 bytes / 4)`, the ratchet's own
unit; `3f86dc52` → `4b330f38`):

| surface | lines before → after | tokens before → after |
|---|---|---|
| `skills/objectstack-data/rules/security.md` | 214 → 216 | 2543 → 2541
(ceiling 2543, headroom 2) |
| `skills/objectstack-data/**` (17 files) | 3807 → 3809 | 40934 → 40932
|
| `skills/**/SKILL.md` (10 files) | 4402 → 4402 | 51903 → 51903 |
| whole `skills/` tree (65 files) | 13427 → 13429 | 155990 → 155988 |
| ratchet "bundle total (whole shipped tree)" | — | 153970 → 153968 |
| `docs/adr/0066-unified-authorization-model.md` | 114 → 114 | 5096 →
5224 (no token ratchet on `docs/adr/**`) |

The +2 lines in the skill file are the natural wrapping of a longer
paragraph; the gate that prices `skills/**` is the token ratchet, and it
went down.

Gates — derived by `node scripts/pm/dispatch-gates.mjs --repo
objectstack-ai/objectstack --commands` off the merge base at `4b330f38`:
29 commands; every exit code captured before any pipe; `--ran`
reconciliation: "29 derived, 29 run, 0 NOT-MEASURED, 0 UNRUN … all 29
recorded an exit code and none of them is 3".

- `node scripts/check-skills-token-ratchet.mjs` → 0 —
"`skills/objectstack-data/rules/security.md` is 2541 tokens (ceiling
2543; headroom 2)"; `--self-test` → 0 (65 cases)
- `node scripts/check-adr-symbol-anchors.mjs` → 0 — "2125 anchors across
140 records resolve"; `--self-test` → 0
- `node scripts/check-adr-links.mjs` → 0; `--self-test` → 0
- `node scripts/check-ci-filter-parity.mjs` → 0
- `node scripts/check-closing-keyword-parity.mjs` → 0; `--self-test` → 0
- `node scripts/check-comment-mask-corpus.mjs` → 0
- `node scripts/check-doc-route-spelling.mjs --advisory` → 0;
`--self-test` → 0
- `pnpm --filter @objectstack/lint run check:doc-formula-expressions` →
first run exit 3 (PREREQUISITE NOT MET: `@objectstack/formula` and
`@objectstack/lint` not built — a refusal, not a measurement); after
`turbo run build --filter=@objectstack/formula
--filter=@objectstack/lint` under `os-verify-lock.sh` (VERDICT
command-exit 0, held 240s) → 0
- `pnpm check:adr-anchors` → 0 · `check:agent-test-spelling` → 0 ·
`check:corpus-claim-drift` → 0 · `check:cross-package-test-inputs` → 0 ·
`check:doc-authoring` → 0 · `check:driver-memory-census` → 0 ·
`check:gitlink-declared` → 0 · `check:nul-bytes` → 0 ·
`check:pm-governed-merges` → 0 · `check:pm-prior-rulings` → 0 ·
`check:refd-timer-probe` → 0 · `check:role-word` → 0 ·
`check:skill-compatibility` → 0 · `check:skill-frame-sync` → 0 ·
`check:skill-identifier-liveness` → 0 · `check:watch-hint-literal` → 0

Reverse verification of the ADR anchors (the gate lists only findings,
so resolution was proven by failure): with `writeCheckPolicies` mutated
to `writeCheckPoliciesNOPE` in the committed ADR,
`check-adr-symbol-anchors` exits 1 with `[unresolved-symbol]
docs/adr/0066-unified-authorization-model.md:93`; restored with `git
checkout HEAD -- PATH`, `git hash-object` of the path equals the HEAD
blob (`01878adb…`) and `git diff HEAD` is empty.

Package tests / typecheck: none owed — the diff touches no `packages/**`
file, so there is no ① dependency closure and no ② package suite; the
whole-repo `pnpm lint` sweep is CI's.

Changeset: `skip-changeset` applies. Both paths are outside every
published package: 0 of the 69 non-private `package.json` manifests list
`skills/`, `docs/adr` or a parent path in `files[]`; positive control —
`RowLevelSecurityPolicySchema` is found in
`packages/spec/dist/security/index.d.ts` (grep exit 0) and the schema's
describe phrase "decided per operation across the applicable policies"
in 9 spec dist files (exit 0); negative — the new skill sentence "chosen
per operation across the applicable" is in no built output under
`packages/` (exit 1). `docs/adr/**` is on the fast track (never
published).

## Acceptance notes

- The dispatch order's suggested ADR phrasing named "the `using` of the
applicable insert-class policies" as the stand-in. The code is wider:
`writeCheckPolicies` runs for `insert` and `update` alike (an `all`
policy included), and when no applicable policy declares `check`, every
applicable policy's `using` stands in for that operation. The landed
text says "each applicable policy's `using`", matching
`RowLevelSecurityPolicySchema.using` ("on an insert or an update, when
no applicable policy for that operation declares `check`, each
applicable policy's `using` also stands in"). The triage note's "an
insert policy's `using` stands in when no `check` is declared" is one
instance of that rule, not the whole rule.
- The example policy `org_isolation` (a hand-written `organization_id ==
current_user.organization_id` select policy) sits beside the file's own
Multi-tenancy section ("⛔ never `single` + your own RLS") and duplicates
the Layer 0 wall. Left as is: not this card, and the token ceiling was
paid without touching it. Observation only, nothing to file.
- `check-doc-formula-expressions` refuses (exit 3) on a fresh worktree
until `@objectstack/formula` and `@objectstack/lint` are built; its
refusal text names the fix. Not a finding.

## 维护者速读(草稿)

**改了什么**:两处受管文本各改一段。ADR-0066「优先级/组合语义」第 3 项那一句,从「同一对象/操作的多条行策略 OR
合并(任一匹配即放行)」改为:读侧按 `using` OR 合并;写侧(insert/update)先按操作在适用策略中选出检查——有声明
`check` 的只用它们,否则每条适用策略的 `using` 顶上——再 OR 合并,逐行判定写出的每一行;并以符号锚引用
`compileFilter` 与 `writeCheckPolicies`。发布技能包 `objectstack-data` 的 RLS
段改写为四句:`using` 放行哪些行;`check` 逐行判定 insert/update 写出的每一行(数组插入与 `multi:
true` 包含)、按操作选定、无 `check` 时 `using` 顶上;`select`/`delete` 策略上的非空 `check`
被拒;默认姿态(只在有策略适用时默认拒绝;无策略适用则不限制,租户墙照旧;update/delete 目标行在没有写侧 `using`
时受调用者的 `select` 策略约束)。示例块两行注释同步。

**为什么改**:这两段是 AI 写 RLS 策略时读的唯一说明,原文把 `check`
当成读过滤的对偶、且不写顶替规则,按它写出的策略与运行时真实执行不一致(NORTH-STAR 优先级规则 4:写给 AI 的文档与 skills
说错一句等于产品缺陷)。执行代码本身是对的,非受管文本已由 objectstack-ai#20268 修正;本 PR 只改受管的两处,不动代码。

**风险与代价(含回滚)**:纯文本;不改 schema、不改运行时。`security.md` 的 token 上限为 2543、余量
0,新增内容以删除同文件重复句付账(2543 → 2541),上限未动、未挪内容到别的文件。29 个派生门禁全绿,ADR
符号锚经反向验证(改坏符号名即变红)。回滚即 revert 本 PR 的单个 commit,无迁移、无数据影响。

**席位意见**:(留空)

**你要做的**:审阅两处措辞后在本 PR 上 Approve(Tier H);落地由席位执行。

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

Co-authored-by: objectstack-fleet[bot] <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 tests tooling

Projects

None yet

2 participants