Repository navigation
feat(lint): rls-predicate-unknown-field / rls-predicate-unknown-user-variable — the reference half of the RLS predicate gate - #17036
Conversation
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
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012GKcPZbMoGq7WPzKLfRBTU
…s-predicate-references
📓 Docs Drift CheckThis PR changes 1 package(s): 23 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 6 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 5 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 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
|
…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
|
Director seat adoption record — POST-MERGE audit — summon #20, Contract review (
|
| 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) andrls-predicate-unknown-user-variable(:189), bothseverity: 'error'(:592, :613), in thegatingtier.- Both run only inside
if (isSupportedRlsExpression(source))(:680) → disjoint from the three shape ids by construction (test pins it). - Field half:
referenceFindings(:574) callssqlPredicateToCelthencompileCelToFilter(formula's own,cel-to-filter.ts:221) against a probe context and reads field paths off the loweredFilterCondition(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 whatfilterFieldPathswalks.record.xclassifies as fieldx(fieldRootsdefault['record'], :437-475), so no false positive from the prefix. Verdicts viaresolveFieldPath;isUnjudgeableskipsunknowable(object-not-in-stack / no-field-map / injected-hop) anddescribeFieldPathVerdictreturns nothing forok— the three skips are the graph's. No second parse of the predicate; the only parsing in lint is of the compiler'sdetailstring (see F7). - Variable half: discovered from the compiler's own
unresolved-variablerefusal (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 fiveRLSCompiler.compileFilterbinds (rls-compiler.ts:272-279: noaccessible_org_ids). Therefore silent onaccessible_org_idsin both positions (test loops every reserved key, both positions, asserted separately) — [finding]current_user.accessible_org_idsis 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
.changeset/rls-predicate-references.md:"@objectstack/lint": minor— the only package whosepackages/**/src/**moved. Clause-②yesrequires ≥1 moved packageminor+: satisfied (Check Changesetsuccess ×2).- Clause-② judged yes, agreeing: two published exports + two closed-set rule ids (C5 widening tells).
- No BREAKING banner. A new
error-level gate narrows theos validate/os build/os lintaccept-set (a stack with a dangling RLS column that built clean now fails). In-lane precedent is split: feat(lint): security-fls-unknown-field — a qualified FLS key naming a field the object does not declare #16998security-fls-unknown-field(same day) tookminorwith no banner; lint 17.4.0's service-automation: evaluate a value-role CEL envelope in the assignment executor and validate it at registerFlow — the executor half of #14149 (spec half landed in PR #15113) #15137 anddefineStackhard-errors on an undeclared hierarchy scope but is silent on an undeclared trigger capability — every autolaunched flow ships inert #14153 banner "BREAKING in the accept-set sense, landing … asminor". The changeset does carry an explicit "What moves for consumers" paragraph, which is the banner's substance. No ADR-0087 marker is required (not declared). Non-blocking — F5. - Governed surfaces: none of
docs/adr/**,.claude/**,skills/**,AGENTS.md,CLAUDE.mdtouched.
Boundary flags
- Runtime repair of the fail-open is [finding] an RLS predicate naming an unknown column in a NEGATION position widens the policy to every row in the tenant instead of denying — the field-existence safety net is -only #17042, correctly not attempted here.
authoring-rules.tssurfaceReason now under-enumerates inputs (F6) — pendingCLI_ONLY → CLI_AND_RUNTIMEflip owner.- [finding]
current_user.accessible_org_idsis 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 (accessible_org_ids) untouched; rule silent either way. - hotcrm's local assertions: hotcrm's call, after this lands.
- Docs: no page lists these ids; the 23 docs the drift bot flagged anchor on
validateRlsPredicateEnforceability/types, not on rule ids — nothing falsified.
Findings
- F1 (non-blocking to this PR; follow-up owed on [finding] an RLS predicate naming an unknown column in a NEGATION position widens the policy to every row in the tenant instead of denying — the field-existence safety net is -only #17042) — The triage escalation clause (5556554578: "measure a fail-OPEN ⇒ p1 +
security") fired and its action was taken by nobody. [finding] an RLS predicate naming an unknown column in a NEGATION position widens the policy to every row in the tenant instead of denying — the field-existence safety net is -only #17042 (created 03:48:34Z) carries no labels — nopriority:p1, nosecurity, nodomain:*— and no comment since 04:45Z. Its scope also under-reads the source: the 04:45 comment records the write face as "NOT ESTABLISHED — no non-test caller in plugin-security src found", butpackages/plugins/plugin-security/src/security-plugin.ts:109importsmatchesFilterConditionand:2822evaluates the compiledcheckfilter against the post-image, with noextractTargetFieldnet on that path (:6102-6146). A negated phantom column on acheckclause permits the write the policy was authored to refuse. Owed: grade [finding] an RLS predicate naming an unknown column in a NEGATION position widens the policy to every row in the tenant instead of denying — the field-existence safety net is -only #17042 per the clause (p1 +security), route it (domain:services), and widen its scope to the write face (read face measured; write face source-established; driver-sql still NOT MEASURED). - F2 (non-blocking) —
packages/lint/src/validate-rls-predicate-enforceability.ts:540-557(referenceConsequence, field half): (i) forusing, "or any arm after the first" is grouped with the fail-OPEN direction; on the read path a non-leading positive miss is kept but inert under||(the real arm still narrows) and closed under&&— only a negated miss is fail-open, in every position (the net never recognises!=/!/not in). (ii) forcheck, the closed leg is attributed to "the safety net recognises only a LEADING …" —computeWriteCheckFilterapplies no safety net; polarity alone decides viamatchesFilterCondition. Direction and prescription are right; mechanism is over-broad/misattributed. Over-warns in the conservative direction. Fix in the [finding] an RLS predicate naming an unknown column in a NEGATION position widens the policy to every row in the tenant instead of denying — the field-existence safety net is -only #17042 round or a docs-nit PR. - F3 (non-blocking, process) — No contract-review-tier verdict exists anywhere on the record for a PR merged under
needs:contract-review; the asserted "at-tier review" was in-session with the dev. The label is still on the PR and absent from the card (H31 split). This review is the record; clear the PR label against it. - F4 (non-blocking, process) — PR body carries no machine-readable
Clause-②: yesline;## Clause-② — re-derived, not copied(body line 90) matchesCLAUSE2_NEAR_MISS_LINE, notCLAUSE2_KEY_LINE. The gate readyesfrom theneeds:contract-reviewcarrier label alone (declarationFromPullRequest,check-changeset-no-major.mjs:1198-1230). - F5 (non-blocking) —
.changeset/rls-predicate-references.md:1-3:minorwithout a BREAKING accept-set banner for a newerror-level gate; consistent with feat(lint): security-fls-unknown-field — a qualified FLS key naming a field the object does not declare #16998, inconsistent with 17.4.0's service-automation: evaluate a value-role CEL envelope in the assignment executor and validate it at registerFlow — the executor half of #14149 (spec half landed in PR #15113) #15137/defineStackhard-errors on an undeclared hierarchy scope but is silent on an undeclared trigger capability — every autolaunched flow ships inert #14153 form. Consumer impact is stated in prose. - F6 (non-blocking) —
packages/lint/src/authoring-rules.ts:1531-1535:surfaceReasonsays the rule "readsstack.permissions[]"; it now also readsstack.objects(indexObjectGraph(cfg)at rule file :657). Conclusion unchanged (CLI_ONLY;objectsis in the per-write snapshot). Owner: the runtime-door flip. - F7 (non-blocking) —
unresolvedVariablePath(rule file, just above :386) regex-parses the compiler'sdetailwording (variable "<path>" …,cel-to-filter.ts:501,506). If that wording changes,resolveReferencesreturnsfilter: null(:447-449) and both halves go silent for every predicate that referencescurrent_user.*. Repo-levelTest Corewould catch it through lint's positive cases; there is no in-formula pin. Consider a structuredpathon theunresolved-variableresult.
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
…: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>
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_RULEStable (not just this rule) at one site — asales_managerset withopportunity_private_owner_onlyon acrm_opportunitythat declares its columns:usingat that sitebilling_address.country == "US"rls-predicate-unenforceable— CONTROLis_private == = falserls-predicate-unparseable— CONTROLis_private_nope == false || owner_id == current_user.idrls-predicate-unknown-fieldis_private == false || owner_id == current_user.noperls-predicate-unknown-user-variableis_private == false || owner_id == current_user.id(shipped)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 nosharingModel) 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.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.
extractTargetFieldrecognises only a leadingfield ==/=/in. For a negation, or for any arm after the first, it returnsnull, the policy is kept,droppednever 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:
usingis_private == false— real narrowing, CONTROLis_private(declared)nope == false— phantom, positive, CONTROLnope(missing)nope != "x"null(not recognised)!(nope == 1)null(not recognised)!(nope in ['a'])null(not recognised)is_private == false || nope != "x"is_private(declared)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.tswas already imported by this file forrecordsOfalone; reaching the field set was three names added to that existing import (indexObjectGraph,resolveFieldPath,isUnjudgeable, plusdescribeFieldPathVerdict/listNames/suggestNamefor 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.
compileCelToFilterlowers the predicate against a probe context; every producer of a field key incel-to-filter.ts(emit,lowerMembership,lowerStringMethod) writes the path as the condition's key, so the loweredFilterCondition's keys are the columns the driver will be handed. Those resolve throughobject-graph.ts, inheriting its three skips unchanged.Variables are discovered from the compiler's own refusal: an unbound key makes
resolveValueraiseunresolved-variablenaming the path. The known set is derived, not transcribed —RESERVED_RLS_MEMBERSHIP_KEYSfrom@objectstack/spec/contracts, the keys anIRlsMembershipResolvermay never supply because the kernel already owns them, and the same setRLSCompiler.compileFilterbuilds itsRLSUserContextfrom. 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, andRLSUserContextSchemainpackages/spec/src/security/rls.zod.tswas deliberately not used — it still spells the org keytenantIdand carriesdepartment/attributesthe 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.rlsMembershipand references them as membership tests;RowLevelSecurityPolicySchemadocuments the pattern under §7.3.1 andrls-predicate-unparseable's own hint recommends it. In aninposition 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 —
compileFilterstages an entryif (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. Soowner_id == current_user.nopeis refused whileassigned_to_id in current_user.team_member_idsstays 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:examples/app-showcaseplugin-securityseed setsobjectskey at all. Declarecrm_opportunityand it reports 2, which is what a control is forThe showcase reading, at the real shipped site (
showcase_contributor/task_own_rowsonshowcase_task, shippedusing: assignee == current_user.email) — three firing controls and two nonsense controls: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…to5dbe239f…) 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 bygit checkout HEAD --, verified by blob-hash equality and an emptygit 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--jsonconsumers 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 indist/index.js,dist/index.cjs,dist/runtime.js,dist/runtime.cjs,dist/index.d.tsanddist/index.d.cts— with a positive control (rls-predicate-unenforceablehits the same paths) and a negative control (an invented symbol hits nothing). So the changeset grades@objectstack/lintminor, neverpatch: 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.lazy-deps.test.tsandruntime-lazy-deps.test.tsself-skip whenpackages/lint/distis 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/contractsimport could disturb, so they are built and run rather than left silently skipped.pnpm --filter @objectstack/lint typecheck— pass, includingcheck:test-typecheck.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-authoringwent 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.eslint . --no-inline-configover its own population of 6396 files — 0 errors, 0 warnings.Notes for the reviewer
packages/lint/src/index.tscarries a minimal, purely additive edit — two names added to one existingexport {}block and a comment above it. A sibling is live in this package this round against a different file.authoring-rules.tswas deliberately not touched. This rule now readsstack.objectsas well asstack.permissions, so itssurfaceReasonprose no longer enumerates everything it reads. The reason's conclusion is unchanged and still correct — the sibling entry records thatobjectsis carried by the per-write snapshot (CONTEXT_STACK_KEYS), so the pending runtime-door flip is still "only the declaration". Flagged rather than edited, because that file is outside this dispatch's file surface.current_user.accessible_org_idsis reserved by the contract but is not one of the five keysRLSCompiler.compileFilterbinds, so a predicate using it fails closed today. That is already filed as [finding]current_user.accessible_org_idsis 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 and is not this PR's call. Reading the reserved superset is the conservative choice here: it keeps this rule silent on that key, so whichever way [finding]current_user.accessible_org_idsis 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 is decided, this rule does not have to move.scalar.length === 0 || member.length === 0, which passes on whichever position happens to be silent — and this rule is silent in both, so the disjunction pinned nothing about position and would have survived a position-blind rewrite. It now asserts both positions separately.reference-integrity-suite.test.tsprose comment claimingindexObjectGraph"names four rules" was already stale before this PR (the mergedsecurity-fls-unknown-fieldmade it five) and is stale by one more now. No gate reads it; noted, not edited.Generated by Claude Code