Skip to content

feat(lint): rls-predicate-unknown-field / rls-predicate-unknown-user-variable — the reference half of the RLS predicate gate - #17036

Merged
baozhoutao merged 6 commits into
mainfrom
claude/issue-16119-rls-predicate-references
Sep 9, 2026
Merged

baozhoutao merged 6 commits into
mainfrom
claude/issue-16119-rls-predicate-references

Conversation

@claude

@claude claude Bot commented Sep 9, 2026 •

Copy link
Copy Markdown
Contributor

Part of #16119

The three shipped rls-predicate-* rules judge a predicate's shape. Nothing judged what it points at. This adds the reference half beside them, without touching them.

The defect, re-measured on this tree before building

The card's four injections, driven through the whole AUTHORING_RULES table (not just this rule) at one site — a sales_manager set with opportunity_private_owner_only on a crm_opportunity that declares its columns:

using at that site before after
billing_address.country == "US" rls-predicate-unenforceable — CONTROL unchanged
is_private == = false rls-predicate-unparseable — CONTROL unchanged
is_private_nope == false || owner_id == current_user.id nothing rls-predicate-unknown-field
is_private == false || owner_id == current_user.nope nothing rls-predicate-unknown-user-variable
is_private == false || owner_id == current_user.id (shipped) nothing nothing — negative control

Both controls fired in the before leg, so the two zeros were never "the linter did not reach the site". The one non-RLS finding the table also produces (security-owd-unset, the fixture declares no sharingModel) is byte-identical across all five rows, so it is background rather than a second report — asserted mechanically in the suite, not by eye.

The card stands. Both silent rows were still silent on origin/main.

⚠️ The card's fail-CLOSED reading does not generalise — one direction is fail-OPEN

Raised in review and confirmed here, first-hand, before the prose was rewritten. The detection is unchanged; what the PR claimed the miss costs was wrong.

extractTargetField recognises only a leading field == / = / in. For a negation, or for any arm after the first, it returns null, the policy is kept, dropped never increments and the deny sentinel never arms. The phantom column then lowers to a negated constraint, and a row that has no such column satisfies it (noValueSatisfiesNegation: $ne / $nin / $notContains).

Measured on the driver-memory matcher, three rows, none carrying the column:

using net sees leading field policy rows matched
is_private == false — real narrowing, CONTROL is_private (declared) kept 1/3
nope == false — phantom, positive, CONTROL nope (missing) dropped → deny 0/3
nope != "x" null (not recognised) kept 3/3
!(nope == 1) null (not recognised) kept 3/3
!(nope in ['a']) null (not recognised) kept 3/3
is_private == false || nope != "x" is_private (declared) kept 3/3

The write path has the same asymmetry, measured against the same controls on matchesFilterCondition: a positive phantom constraint refuses the post-image, a negated one is satisfied vacuously and permits the write the policy was written to refuse.

⛔ Not a cross-tenant leak — tenancy is a separate layer and it holds; what is defeated is the narrowing authored inside the wall. driver-mongodb follows the same shared ruling; driver-sql is NOT MEASURED and is expected to fail closed by raising no such column.

The runtime repair is #17042 and is deliberately not attempted here. What changed in this PR is prose in three places — the finding's message, the file docblock and the changeset — so an author is never told "this denies everything" about a predicate that in fact matches everything. The unknown-variable half really is fail-closed in every position (the compiler refuses it under ! and in a trailing || arm alike), and its message still says so without qualification; a test pins that it does not acquire the fail-OPEN wording. Five more cases pin that the fail-open shapes are reported at all — a rule that only caught the leading position would have satisfied the card and missed the dangerous half.

What the estimate turned out to be

The dispatch was right and triage's "needs new capability" was stale. object-graph.ts was already imported by this file for recordsOf alone; reaching the field set was three names added to that existing import (indexObjectGraph, resolveFieldPath, isUnjudgeable, plus describeFieldPathVerdict / listNames / suggestName for the prose). No new input path was built.

How the two answers are obtained

Neither half re-parses the predicate — that fork is what this file's own docblock refuses.

Fields are read off the pushdown compiler's own output. compileCelToFilter lowers the predicate against a probe context; every producer of a field key in cel-to-filter.ts (emit, lowerMembership, lowerStringMethod) writes the path as the condition's key, so the lowered FilterCondition's keys are the columns the driver will be handed. Those resolve through object-graph.ts, inheriting its three skips unchanged.

Variables are discovered from the compiler's own refusal: an unbound key makes resolveValue raise unresolved-variable naming the path. The known set is derived, not transcribed — RESERVED_RLS_MEMBERSHIP_KEYS from @objectstack/spec/contracts, the keys an IRlsMembershipResolver may never supply because the kernel already owns them, and the same set RLSCompiler.compileFilter builds its RLSUserContext from. A key added there stops being reported the same day, with no edit in this package. The card's five-name hotcrm guard was not copied, and RLSUserContextSchema in packages/spec/src/security/rls.zod.ts was deliberately not used — it still spells the org key tenantId and carries department / attributes the RLS compiler never binds, so reading it would judge policies against a shape the runtime does not have.

The boundary that lets the variable rule exist at all

An app stages arbitrary sets into ExecutionContext.rlsMembership and references them as membership tests; RowLevelSecurityPolicySchema documents the pattern under §7.3.1 and rls-predicate-unparseable's own hint recommends it. In an in position an unknown key is indistinguishable from a correct one and is never reported.

It is decidable in the other positions only because the merge is array-only — compileFilter stages an entry if (Array.isArray(value)) and never lets one clobber a named field, so the sole value an app-staged key can ever hold is an array, which a scalar position cannot use on any request. The position question is asked of the compiler, not of a model of it: bind the key to a scalar and re-run. So owner_id == current_user.nope is refused while assigned_to_id in current_user.team_member_ids stays silent, and a key used in both positions takes the membership answer — the conservative direction.

Corpus — one reading, and two zeros that are ⛔ NOT readings

The earlier revision of this section reported three zeros as if they were three readings. Two of them are vacuous, and the showcase figures were double-counted (the set is exported both under its own name and inside allPermissionSets, so every policy was counted twice). Corrected:

corpus RLS clauses judgeable new findings is it a reading?
examples/app-showcase 3 3 0 yes — controls below
plugin-security seed sets 71 0 0 ⛔ no — every policy target is an object that package does not declare, so all 71 are skip-1 by construction
hotcrm built-permissions fixture 4 0 0 ⛔ no — the fixture carries no objects key at all. Declare crm_opportunity and it reports 2, which is what a control is for

The showcase reading, at the real shipped site (showcase_contributor / task_own_rows on showcase_task, shipped using: assignee == current_user.email) — three firing controls and two nonsense controls:

  • injected dangling column → 1 · injected unknown scalar variable → 1 · injected fail-open negation shape → 1
  • injected membership test against an unknown key → 0 · real field + real variable → 0

Object count is 22 distinct objects as enumerated from examples/app-showcase/src/data/objects/*.ts; the load-bearing figure is the judgeable one, and all 3 clauses resolve against declared objects.

Reverse verification

The reference pass was ablated on the committed implementation — the call site replaced, the mutation proven on disk by blob hash (e1b5efaf… to 5dbe239f…) before any test ran. 22 tests failed, 64 still passed: the positives die, the skips and negative controls do not, which is the shape that distinguishes a real gate from an always-fires one. Restored by git checkout HEAD --, verified by blob-hash equality and an empty git diff HEAD, not by an exit code. (Re-run on the corrected tree; the first revision measured 17/63 before the five fail-open cases were added.)

Clause-② — re-derived, not copied

yes, agreeing with the dispatch. The diff adds two published exports to @objectstack/lint's public entry, and two members to the closed set of rule ids that allowlists and --json consumers key on (this file's own docblock states that consumers key on the id). Both are C5 widening tells. Publish set measured rather than assumed: files[] is ["dist","README.md","CHANGELOG.md"], and after a build both new ids appear in dist/index.js, dist/index.cjs, dist/runtime.js, dist/runtime.cjs, dist/index.d.ts and dist/index.d.cts — with a positive control (rls-predicate-unenforceable hits the same paths) and a negative control (an invented symbol hits nothing). So the changeset grades @objectstack/lint minor, never patch: exports and behaviour are added, nothing is removed or narrowed, and the three existing ids are byte-identical on every input.

Verification

All re-run on the final head 530469ff4c.

  • pnpm --filter @objectstack/lint test — 103 files / 3654 tests pass, 0 skipped. ⚠️ An intermediate run reported "5 skipped": lazy-deps.test.ts and runtime-lazy-deps.test.ts self-skip when packages/lint/dist is absent, and this package must be built before they measure anything. They police the dep-tier contract, which is exactly what a new @objectstack/spec/contracts import could disturb, so they are built and run rather than left silently skipped.
  • pnpm --filter @objectstack/lint typecheck — pass, including check:test-typecheck.
  • Gate families derived mechanically with scripts/pm/dispatch-gates.mjs --commands, then reconciled: 56 derived, 56 run, 0 UNRUN. 53 exit 0. Three exit 3 = PREREQUISITE NOT MET and print "this is NOT a pass and NOT a finding" themselves — check:lean-entry-closure, check:dual-build-cjs-loads, check:type-check-debt. All three need the full workspace build closure, which is CI's run; recorded as NOT MEASURED, not as green.
  • check:doc-authoring went red on the first draft of the corrected prose, and it was right: the new message cited #17042, and a runtime string reaches authors, operators and generated surfaces who cannot resolve a bare tracker id. The id moved to the adjacent // comment (the remedy the gate itself names) and a test now pins that neither the message nor the hint carries one. ⛔ No baseline entry was added — that path is maintainer-only.
  • Repo-wide lint was run in full, not narrowed: eslint . --no-inline-config over its own population of 6396 files — 0 errors, 0 warnings.

Notes for the reviewer


Generated by Claude Code

WIP: the reference half of the RLS predicate gate.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012GKcPZbMoGq7WPzKLfRBTU
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012GKcPZbMoGq7WPzKLfRBTU
@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation tests tooling labels Sep 9, 2026
@github-actions

github-actions Bot commented Sep 9, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/lint, touching 20 documentable anchor(s). ⚠️ 1 changed file(s) yielded no anchor (packages/lint/src/index.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

23 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json c0e4bc749d162a7cae17f54f94e6bab7c9c46729.

⛔ 6 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/lint/src/index.ts) — pages documenting those are invisible to this run
  • the SDK route bridge reached 60 of 216 client-bound route-ledger rows — the other 156 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 156: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 100 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 5 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 c0e4bc749d162a7cae17f54f94e6bab7c9c46729 → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json c0e4bc749d162a7cae17f54f94e6bab7c9c46729

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

…th directions

The rule's detection was right; what it SAID the miss costs was not. The
consequence prose claimed both reference misses fail closed. That holds for an
unresolved current_user value, which the compiler refuses in every position, and
it does not hold for a missing field: extractTargetField recognises only a
leading `field ==` / `=` / `in`, so a negation or any later arm leaves the policy
kept, and a row without that column satisfies the negated constraint. Measured
3/3 rows against a 1/3 real narrowing and a 0/3 phantom positive, on the read
path and on matchesFilterCondition alike.

The message, the docblock and the changeset now say which direction applies, and
say it with the limits intact: not a cross-tenant leak, driver-sql not measured.
The runtime repair is tracked separately and is not attempted here.

Also tightens the current_user position pin, which asserted a disjunction that
passed on whichever position happened to be silent.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012GKcPZbMoGq7WPzKLfRBTU
check:doc-authoring is right: a runtime string reaches authors, operators and
generated surfaces, none of whom can resolve a bare issue number. The id moves
to the adjacent comment, where the reader who can resolve it is already looking,
and a test pins that neither the message nor the hint carries one again.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012GKcPZbMoGq7WPzKLfRBTU

os-bill commented Sep 9, 2026

Copy link
Copy Markdown
Collaborator

Director seat adoption record — POST-MERGE audit — summon #20, session_01Tep4AYXZvyBA7jsvne5KZV (os-bill), 2026-09-09T07:10Z. This PR was flipped ready (04:44:58Z), enqueued (04:46:19Z) and merged (05:11:12Z, e4fd55d9b0) by baozhoutao (the domain:devx seat, session_012GKcPZbMoGq7WPzKLfRBTU) with needs:contract-review still on the PR. The landed note on card #16119 (5596178544) asserts an at-tier review FAIL followed by a patch round, but no review text exists on the PR (0 reviews, 0 inline comments) or on the card; a verdict that is not on record is not on record. The PR still carries the carrier. The verdict below is the record at the mandated tier: adopted verbatim from an isolated contract-review subagent (explicit model = CONTRACT_REVIEW_TIER). Transcript tier check before adoption: every harness-stamped model field in the subagent transcript reads claude-fable-5-1 (103 stamps, no other value). Merged head re-read at posting time = 530469ff4c.


Contract review (CONTRACT_REVIEW_TIER, isolated seat, POST-MERGE) — PR #17036 @ 530469ff4ce8c690570e5196dc4102ac1ca7c1e6

Verdict: PASS WITH FINDINGS

The landed code is correct, conforms to the dispatch, and needs no code follow-up. What is owed is card-side: #17042 (the runtime fail-open this PR surfaced) is sitting unlabelled three hours after filing, its scope omits a write face that is established on the source, and the triage escalation clause (p1 + security) has been applied by nobody. Details in F1.

Increment verified: git diff e4fd55d9b0^1 e4fd55d9b0 is byte-identical to git diff 2e8e1185b5 refs/pr-review/17036 (911 lines; squash merge, single parent 86f42461ad). Four files: .changeset/rls-predicate-references.md (+24), packages/lint/src/index.ts (+6), packages/lint/src/validate-rls-predicate-enforceability.ts (+423/−8), its .test.ts (+356).

Ruling conformance

Ruling (card 5556554578 / claim 5595026502) Reading on e4fd55d9b0
Claim before code; dev inherits, posts no second claim Claim 5595026502 names session_012GKcPZbMoGq7WPzKLfRBTU + branch; all 5 non-merge commits carry that Claude-Session; no second claim. ✓
Do not modify the three shape rules RLS_PREDICATE_UNENFORCEABLE :175, _UNPARSEABLE :177, _OVER_BUDGET :179 unchanged; the shape branch's only edit hoists where/path to whereEarly/pathEarly with identical expressions (:672-679, :703-704). Behaviour-preserving. ✓
current_user.* set DERIVED, not transcribed PRERESOLVED_USER_KEYS = new Set(RESERVED_RLS_MEMBERSHIP_KEYS) (:327) from @objectstack/spec/contracts (rls-membership-resolver.ts:89-96). hotcrm's five-name list not copied. ✓
Do not retire/reference hotcrm assertions No hotcrm path in the diff; PR body says untouched. ✓
Escalation clause: report, do not act/widen Reported (5595933739), filed as #17042, not widened into the PR. ✓ on the dev's side — but see F1: the clause's action was never taken.
File surface: rule file + tests + id enumerations + .changeset/ Exactly that; index.ts edit is 2 names + a comment (:358-359). authoring-rules.ts deliberately untouched and flagged. ✓
Card relation in PR body only, no commit trailers Part of #16119 in body; no Refs/Part of in any commit message. ✓
Draft PR, PM lands Enqueued 04:46:19Z, merged 05:11:12Z by baozhoutao via merge queue.
Acceptance 1–6 All four injections pinned (it.each table); before-leg silence accepted from the card's own measurement (two firing controls at the same site) and pinned mechanically post-hoc by "NOTHING else in the whole rule table reports either miss"; negative controls pinned; messages name the cost; corpus zeros with controls (two of three corrected to "not readings"); publish set measured. ✓
Contract review before landing ⛔ No contract-review text exists on the PR (reviews [], review comments [], 1 bot comment) or on the card. The landed note's "At-tier contract review FAIL → one patch round" is unverifiable; the "reviewer" and the dev share one session id (session_012GKcPZbMoGq7WPzKLfRBTU on the commits, the claim, and the #17042 filing). Merged with needs:contract-review still on the PR (it is on the PR now; the card no longer carries it — a split pair). This review is the record.

Derived judgments

(a) Rule ids, level, predicate — and "no second parser".

  • rls-predicate-unknown-field (:184) and rls-predicate-unknown-user-variable (:189), both severity: 'error' (:592, :613), in the gating tier.
  • Both run only inside if (isSupportedRlsExpression(source)) (:680) → disjoint from the three shape ids by construction (test pins it).
  • Field half: referenceFindings (:574) calls sqlPredicateToCel then compileCelToFilter (formula's own, cel-to-filter.ts:221) against a probe context and reads field paths off the lowered FilterCondition (filterFieldPaths :480). Verified every key producer in the compiler keys on the field path — emit (:421), lowerMembership (:362), lowerStringMethod (:404) — field-to-field emits { $field } (:346), and the only combinators are $and/$or/$not (:296-302), which is exactly what filterFieldPaths walks. record.x classifies as field x (fieldRoots default ['record'], :437-475), so no false positive from the prefix. Verdicts via resolveFieldPath; isUnjudgeable skips unknowable (object-not-in-stack / no-field-map / injected-hop) and describeFieldPathVerdict returns nothing for ok — the three skips are the graph's. No second parse of the predicate; the only parsing in lint is of the compiler's detail string (see F7).
  • Variable half: discovered from the compiler's own unresolved-variable refusal (resolveValue, cel-to-filter.ts:491-508), position decided by re-compiling with a scalar probe (userVariableIsScalarPositioned :386); in-position keys are never reported (§7.3.1). Pre-resolved set = RESERVED_RLS_MEMBERSHIP_KEYS = id, organization_id, positions, org_user_ids, accessible_org_ids, email — a strict superset of the five RLSCompiler.compileFilter binds (rls-compiler.ts:272-279: no accessible_org_ids). Therefore silent on accessible_org_ids in both positions (test loops every reserved key, both positions, asserted separately) — [finding] current_user.accessible_org_ids is reserved and ADR-0105 D11 calls it core-resolved, but nothing resolves it into the RLS variable bag — every predicate using it fails closed #16518 stays untouched. ✓

(b) Doors and dep tier. authoring-rules.ts:1525-1537: tier: 'gating', input: 'parsed', commands: ALL, surfaces: CLI_ONLY. Runs under os validate (validate.ts:302), os compile (compile.ts:383, the 'build' axis — the changeset's "os compile" is accurate), os lint (lint.ts:514). Not run at runtime-publish (CLI_ONLY), though the module is bundled into dist/runtime.* via runtime-gate.ts:57 → authoring-rules.js. @objectstack/spec/contracts is the first such import in lint src, but lint already imports the @objectstack/spec root (6 files) and 8 other subpaths; LAZY_DEPS is typescript/sucrase/ajv/ajv-formats only; lean-entry-closure rides Lint & Repo Gates (green). Dep-tier contract respected. ✓

(c) Consequence prose vs. runtime source. Verified: extractTargetField (security-plugin.ts:7539) is ^\s*([a-z_][a-z0-9_]*)\s*(?:==|=|IN|in)(?=\s|\() — leading only, !=/! deliberately unrecognised; if (!targetField) return true keeps the policy (:5910); dropped++ only for an extracted-and-missing field (:5915); sentinel only when layer1 == null && dropped > 0 (:5921-5923). noValueSatisfiesNegation (memory-matcher.ts:161) = $ne/$nin/$notContains, admitted through the pre-switch guard (:393). Variable half: resolveValue throws in every position; compileFilter collects (:318-345) → filters.length === 0 → RLS_DENY_FILTER, one WARN via warnFailClosedDenial (:357). All as the shipped message states. The write face is also established on the source: matchesFilterCondition is imported at security-plugin.ts:109 and evaluated against the post-image at :2822 inside step 3.6 (ADR-0058 D4); computeWriteCheckFilter (:6102-6146) compiles check clauses with no extractTargetField net; in matches-filter.ts $ne → !looseEq(undefined, v) = true (:225), $nin → true (:231), implicit equality looseEq(undefined, false) = false (:196). So a negated phantom permits, a positive phantom refuses — the PR message's write-path claim is right. Two precision nits: F2. Escalation clause: partially honoured — F1.

(d) Pre-existing ids untouched — yes (see conformance table).

(e) Published surface / keys / codes / doc-authoring. New exports from @objectstack/lint index: RLS_PREDICATE_UNKNOWN_FIELD, RLS_PREDICATE_UNKNOWN_USER_VARIABLE (:358-359); two new members of the closed rule-id set that --json consumers and allowlists key on. No new payload keys, no spec change, no new error codes beyond the two ids. Repo-wide, nothing else enumerates rls-predicate-* ids (only CHANGELOG.md and index.ts; content/docs has zero mentions), so no catalog/doc page was left stale. check:doc-authoring: #17042 lives only in a // comment (:533); message/hint carry no #\d{3,} (test-pinned); scripts/doc-authoring-prose-id.baseline.json untouched (git diff --stat empty; no entry for this file). ✓

Semver / changeset

Boundary flags

Findings

CI at merge

Head 530469ff4c: 41 check runs, 34 distinct names (7 re-runs on enqueue). Latest-per-name: 29 success, 5 skipped, 0 failure — skipped: Auto Label, Build Docs, Check PR Size, Console Pin Gate, Packed-tarball smoke (opt-in) (path-filtered/opt-in). All 7 ruleset-required contexts success: TypeScript Type Check, Test Core, Dogfood Regression Gate, Build Core, Temporal Conformance (live PG + MySQL), Lint & Repo Gates, Governed Surface Queue Guard. Combined status: Vercel success. Dev-report open_questions: [] in both reports. out_of_scope_findings answered: #16518 exists (open, p1+security, domain:services) and the superset read keeps this rule silent on it — confirmed; surfaceReason staleness — confirmed (F6); reference-integrity-suite.test.ts "four rules" comment — doc nit, no gate reads it; escalation correction — confirmed and extended (F1); corpus double-count correction — accepted (PR body table now states judgeability).

Implemented-by: branch claude/issue-16119-rls-predicate-references (merged as e4fd55d9b0)
Reviewed-by: director seat summon #20 (isolated fable subagent, transcript-verified before adoption)

{"pr":17036,"head":"530469ff4ce8c690570e5196dc4102ac1ca7c1e6","verdict":"PASS WITH FINDINGS","blocking":[],"clause2":"yes","semver_ok":true,"governed":false,"ci":"34 names latest-per-name: 29 success, 5 skipped (path-filtered/opt-in), 0 failed; all 7 ruleset-required contexts success"}


Generated by Claude Code

akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 17, 2026
…:contract-review (objectstack-ai#17831)

The half-state patrol could see a contract-review gate on an OPEN
carrier (H31 compares the card's and the PR's, H51 reads one outliving a
verdict on the current head, H53 reads one with no increment behind it)
and it could see a gate REMOVAL (H35). It could see nothing at the other
end: a pull request that MERGED, or a card that CLOSED, with
`needs:contract-review` still on it. 载体纪律's invariant is 「开着的载体恒 =
真实待审」, so every one of those labels still tells a later reader — a seat,
a post-merge tier audit, any candidate query that forgets to scope
`state=open` — that a real contract review is owed on work that is
already in `main`. This adds H61, one report-only row per landed
carrier, printing the merge or closure stamp and the PR's head sha, so
the post-merge tier audit has a list instead of a memory.

It costs no request. Both populations were already in hand by the time
the row runs: merged PRs from H8's 8-day window
(`listRecentlyMergedPullRequests`) and closed cards from H22's 3-day
window (`listRecentlyClosedIssues`), whose list rows already carry
`labels`, `merged_at` / `closed_at` and `head.sha`. Exit codes, bands
and every other row are untouched — a completed sweep still exits 0
whatever it finds, and the script still never writes a label: clearing a
carrier is 清标即落地, a seat's audited write with its provenance comment,
and a sweeper striking a review gate would be issuing the verdict.

Fixes objectstack-ai#17510

## Acceptance notes

### The director seat's ask, verbatim

From comment 5597753733 on objectstack-ai#17040 (os-bill, 2026-09-09T07:11Z, "What the
mechanisable fix should now also cover"), quoted exactly as written:

> A `check-half-states` row for a **closed card still carrying `pm:*` or
a carrier** (row 1 left objectstack-ai#16231 closed with `pm:queue` +
`needs:contract-review`; row 2 left the carrier on a merged PR).

The `pm:*` half of that sentence is H22's and stays there. The CARRIER
half is H61. The card paraphrases it as 「merged PR / closed card still
carrying a gate label」; the sentence above is the actual wording.

### Premises, re-checked on `origin/main` at `f830fa21`

1. **Holds.** No predicate named a merged PR or a closed card still
carrying the gate. `h51SpeaksAbout` excludes a merged or closed PR by
name; H31's population test excludes a merged delivering PR and a closed
card by name; H35 reads removal events; H8 reads `pm:dispatched`; H22
reads `pm:*` on closed cards and files no row at all since objectstack-ai#14072.
`CONTRACT_REVIEW_LABEL` is reused — no second spelling, pinned the way
H51 and H53 pin it.
2. **Half false, and corrected in place.** H60 is indeed the highest
number in use and H99 is the self-test sentinel, so H61 is the next free
one. But the parenthetical is wrong: the top docblock's "## The
invariants" index stops at **H28**. H29 through H60 have no entry there
at all. H59 and H60 were added as a banner comment block above their
predicate plus a `HALF_STATE_FAMILY_BAND` entry, and
`familyRegistryCoverage` is the mechanical index that holds the band
registry equal to the codes the sweep actually pushes. H61 follows that
idiom; adding a lone H61 line to an index that stops thirty-three rows
earlier would have been worse than leaving it out.
3. **Holds.** `33e07f7c` (PR objectstack-ai#17484) is an ancestor of `origin/main`.
The front end is closed, so this row is residue reporting, not a bypass,
and the row says so in its own text.
4. **Updated, larger, and measured.** See below.

### The measured population, 2026-09-12

`GET
/repos/{owner}/{repo}/issues?state=closed&labels=needs:contract-review`,
one page per repo, both complete (39 and 29 rows, under the 100-row page
size):

| repo | total | merged PRs | closed-UNMERGED PRs | closed cards |
|:--|--:|--:|--:|--:|
| objectstack | 39 | 23 | 2 | 14 |
| objectui | 29 | 17 | 0 | 12 |
| **both** | **68** | **40** | **2** | **26** |

Oldest closure still wearing the label: 2026-08-20T23:58Z (objectstack),
2026-08-30T17:32Z (objectui). The card named three specimens; the class
is an order of magnitude larger.

**What the row reaches, and what it does not.** H61 reads the two
windows the sweep already holds, so a merged carrier is visible for 8
days after it lands and a closed-card carrier for 3. At the patrol's
6-hour cadence that is roughly 32 and 12 consecutive sweeps: every NEW
carrier is seen, repeatedly. What ages out is the standing backlog
above. That boundary is deliberate — the alternative is a
`state=closed&labels=…` listing, which is both a new fetch class and a
second closed-card reader beside `pmLabelListingPath`, whose
`state=open` scoping is the entire mechanism by which maintainer ruling
批 objectstack-ai#13 holds. The header records the number so a reader can tell a short
reach from a clean board.

### Work item B — closed-UNMERGED PRs are EXCLUDED

Stated, not folded. Two of the 68 (objectstack objectstack-ai#16733 and objectstack-ai#14923;
objectui 0 — 2.9%) are pull requests closed without merging, carrier
still on. They are out of this row for three reasons:

1. **The harm model does not reach them.** This row exists because a
gate on a LANDED increment says a review is owed on work already in
`main`; the post-merge tier audit is the consumer. A PR closed unmerged
delivered nothing and owes no post-merge verdict — the shape H51's own
header already calls moot, 「载体不迁移」.
2. **The data path does not hold them.**
`listRecentlyMergedPullRequests` SELECTS on `merged_at`, and its header
says why in as many words: an unmerged closed PR is an abandoned
attempt, not a delivery. Widening that selector would change what H8 and
H49 are handed — two other rows' inputs, and out of scope here.
3. **Size.** 2 of 68, both on one install, neither inside the window
this row reads. A separate count rendered on every run for a class that
contributes nothing to it is a number a reader learns to ignore.

Both legs refuse them, and both refusals are pinned. Writing that pin is
what found a real hole in my own first draft: a closed-unmerged PR
arriving from the PULLS listing carries no `pull_request` field at all,
while `state` is `closed` and `closed_at` is readable — so the card leg
accepted it. `h61SpeaksAboutClosedCard` now refuses a row carrying
`head` as well. Live, the two collections never cross; a population test
that is only correct because of its caller is one refactor from being
wrong.

### The sha is the HEAD, not the merge commit

The row prints `head.sha`. 载体纪律 says a clearing write cites 「所判 head」
and H51 resolves a verdict against `pr.head.sha`, so the head is the
only sha a contract-review record can be matched on. Measured on the
three specimens that filed this card: objectstack-ai#16783 head `47eea7a8` / merge
commit `854639b3`; objectstack-ai#17036 head `530469ff` / merge commit `e4fd55d9`;
objectui#8779 head `a04441f7` / merge commit `6cc48c4e`. The three shas
quoted on the card and in the dispatch are the MERGE COMMITS — a reader
searching a thread for one of them finds nothing. Noted, not filed: a
card-text inaccuracy, not a defect in any code.

### Band, and the repo column

`inventory`. Refused in turn: not `gate` (that band is for a row that
can tell a STRIPPED gate from an ungated one — an absence reading as a
green light; here the carrier is present), not `stall` (nothing is
stopped — the increment merged), not `state` (whose definition says a
LIVE card, while every subject here is merged or closed, ARCHIVE in
ruling 批 objectstack-ai#13's own word for this population). Being first in line for
the body trim is correct for it, and the ledger callout names the family
and the omitted count rather than dropping rows silently. The row text
is deliberately short for this file for the same reason: it fires per
carrier, and 20 of the 23 objectstack merged carriers sit inside the
8-day window today.

No repo column: each install sweeps its own repository through
`resolveSweepRepo`, one token and one board, so this row can never see
more than one and the column would be a constant. The two-install
figures above are a hand measurement taken for the header, not something
a run reproduces.

### Verification

- `node scripts/pm/check-half-states.mjs --self-test` :: exit 0 — 3748
cases pass (3707 before this change, 41 new). Both directions per the
dispatch: a merged PR carrier fires, a closed card carrier fires, an
OPEN PR carrier does not (and H51 still speaks about exactly that PR, so
nothing fell between the two rows), a merged PR without the label does
not, a closed-unmerged PR does not through either leg, unreadable
`labels` is excluded rather than read as unlabelled, an unreadable stamp
yields no row. The row text is pinned on the invariant, the remedy
(清标即落地 / 同笔剥双载体 / the provenance comment / who acts), the "never a label
written from this script" clause, the neighbouring rows it defers to,
and the fact that it prints `CONTRACT_REVIEW_LABEL` rather than a
re-typed literal.
- Exit codes and bands pinned unchanged: `EXIT_PREREQUISITE_NOT_MET` is
still 3, `HALF_STATE_FAMILY_BANDS` still spells
`gate,unregistered,stall,state,inventory`, `isLoudFinding` is false on
the row, and `familyRegistryCoverage().missing.length` is 0.
- All 41 gate families derived by `node scripts/pm/dispatch-gates.mjs
--commands` in this worktree were run; each exit code is in the report
on objectstack-ai#17510.
- No changeset: `scripts/pm/**` publishes nothing from any released
package, which lint.yml calls the textbook `skip-changeset` case in as
many words.

### Out of scope, noted not filed

- The card's and the dispatch's three shas are merge-commit shas
presented as head shas (measured above). Successor: this PR body and the
row's own header carry the correction; no code is wrong.
- objectstack-ai#16231 no longer carries `pm:queue` — the director's row 1 recorded it
on 2026-09-09 and it has since been cleared. The carrier is still on,
which is why it serves as this row's closed-card specimen.

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

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

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants