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
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