Repository navigation
fix(lint,objectql)!: a validation rule reading a field its lookup target does not declare is refused at save, and no longer leaves its sibling rules without a verdict - #24508
Conversation
… rule, not per reference resolvePredicateRelated takes the columns a reference's target does not declare out of the shared predicate read and lists them on the binding; resolveTraversalScope refuses a rule only for its own undeclared columns, so a valid sibling rule through the same lookup keeps the verdict it gives alone. The loader, its authority and its bound are unchanged. Claude-Session: https://claude.ai/code/session_01XPyxHgkHDoFuEza6RvCJtd Co-authored-by: Claude <noreply@anthropic.com>
…t does not declare The validation-rule pass now judges the column a hydrated rule condition reads one hop through a reference against the target's field index (declared fields, injected columns and id), under the existing expression-invalid rule, naming the field and the target. One body with the formula pass's refuseUndeclaredHopField, for os validate, os build and the object save door. A reference read more than one hop deep is left to the traversal checks' own refusal. Claude-Session: https://claude.ai/code/session_01XPyxHgkHDoFuEza6RvCJtd Co-authored-by: Claude <noreply@anthropic.com>
…ooted stack Claude-Session: https://claude.ai/code/session_01XPyxHgkHDoFuEza6RvCJtd Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XPyxHgkHDoFuEza6RvCJtd Co-authored-by: Claude <noreply@anthropic.com>
…he receiver census Claude-Session: https://claude.ai/code/session_01XPyxHgkHDoFuEza6RvCJtd Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift Check5 anchor(s) derived from 2 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run. What this run could not see
Coarse fallback — 21 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 57b6c06cdf356dcb0fab67aeec303dbcf5a8494a && git checkout 57b6c06cdf356dcb0fab67aeec303dbcf5a8494a
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 7e58e0a27998194640c145344b7519f1ccc9fad4 69f76c6ef1a3e64bd6c7d1c8faaea31f84d3686b && git checkout -B drift-repro 7e58e0a27998194640c145344b7519f1ccc9fad4 && git merge --no-ff 69f76c6ef1a3e64bd6c7d1c8faaea31f84d3686b
node scripts/docs-audit/affected-docs.mjs --json 7e58e0a27998194640c145344b7519f1ccc9fad4 |
Contract reviewServed-tier: Inputs: card #24468 (body; triage 6260730682, claim 6262728643, dispatch 6262791718, the dev's os-dev-report 6263711750, all read as claims; the Check-runs on the head, four reads. First (about 09:10Z): 38 runs, 17 success, 6 skipped, 15 in progress. Second (09:18:53Z) and third (09:21:49Z): 40 runs, 28 success, 6 skipped, the six ① Derived judgments
② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS Generated by Claude Code |
席位核对 ·
|
| Item | Reading |
|---|---|
| Shape | Draft PR → main. The body opens Fixes #24468. 12 files, +514/−12. Not governed. |
| Scope | Engine: resolvePredicateRelated takes each reference's undeclared columns out of the shared predicate read, through the shared undeclaredRelatedFields. It lists them on the binding as undeclaredFields. resolveTraversalScope then refuses a rule only for its own undeclared columns, so a valid sibling gets its alone-verdict. Lint: refuseUndeclaredHopField runs on every hydrated rule condition (top-level and nested then / otherwise), under expression-invalid. No new code. |
| Seat's own read | The diff has one engine.ts hunk (:10245), inside resolvePredicateRelated. resolveOneHopRelated and the formula seam have none, so authority: 'predicate' and its bound are byte-identical. The objectql changeset is patch with Clause-②: no and no arm. |
| Review | CONTRACT_REVIEW_TIER record 6264118179, narrowing face, PASS on this head. |
| Declaration | Clause-②: no (narrowing) on the claim, the PR body and the lint changeset (@objectstack/lint minor, **BREAKING**, adr-0087: not-required (no-migration-prescription)). The objectql changeset is patch, Clause-②: no. |
| Cross-lane | The lint half is declared to domain:spec (6262742638 on #6017, 6262753494 on #18549). The dogfood pin is declared to domain:cli (6263746266 on #6024, 6263757792 on #22648), posted after the record's brief was cut; this answers record ③.9. No objection on any of the four, or on the card. |
| Gates | Dev, on the merged tree: 76 derived, 76 run, 76 exit 0. Core test:repo 82/82. |
| CI | At this read on 69f76c6ef1: every check completed, none failed. |
Disposition:
open_questions: all three accepted (record ③.7, ③.3, ③.8):- the binding carries
undeclaredFields; - the multi-hop skip applies at both sites, with no formula verdict moving;
7e58e0a27was not merged, and CI ran on the merge with it.
- the binding carries
deviations: all accepted (record ③.1–③.6). The local branch the report names is in the dev's own container, which is archived at landing.out_of_scope_findings:- The
conditionalwhenthrough a lookup is filed as A conditional validation rule'swhenthat reads through a lookup (record.account.status) saves at every door, butcheckConditionalevaluates it unhydrated, so every write the rule guards is refused as unevaluable #24512 (finding, for triage), as record ③.10 asked. - The
objectExtensionsbuild-time window stays carried by formula lint admits a read of a field the related object does not declare; the read then nulls every formula through that lookup, valid siblings included #24410's record 6262025060 ③.5.
- The
Landing: ordinary queue (pr_ready + automerge_enable through the relay). Fixes #24468 then closes the card.
Generated by Claude Code
Fixes #24468
Clause-②: no (narrowing) (the save door newly refuses a validation rule it accepted; owes one
CONTRACT_REVIEW_TIERreview, on the narrowing face, before the queue)Dispatched by the
domain:engineseat 1 PM (claim 6262728643,session_01JtSQQ3EWsbNPzyYMug1tXy). Developer session:session_01XPyxHgkHDoFuEza6RvCJtd. This is the predicate-seam twin of #24410 (PR #24466,45bec43cd9), and it follows that PR's pattern.What changes
Engine: per-rule isolation (
packages/objectql).resolvePredicateRelatedreads, per reference, the union of the related columns every validation rule reads. When one column was undeclared on the target,resolveOneHopRelatedanswered the whole referenceundeclared-field. Every rule through that lookup then had no verdict and refused the write, naming the other rule's field.resolvePredicateRelatednow takes the undeclared columns out of the shared read, using the sharedundeclaredRelatedFieldshelper (no second definition of "declared"). It lists them on the binding asundeclaredFields. The loader reads the declared rest underauthority: 'predicate', with the same bound as before.resolveTraversalScope(validation/rule-validator.ts, the binding's consumer) first refuses a rule only for its OWN undeclared columns, naming only those. A rule naming none of them is judged on the row exactly as it is alone.unavailable: 'undeclared-field'), so no rule meets a bare id.resolveFormulaRelated,formulaHopScope),resolveOneHopRelated, and the predicate read's authority and bound. No undeclared column enters the system-authority read.Lint: save-door refusal (
packages/lint, thedomain:specrider declared in 6262742638).refuseUndeclaredHopField(#24410) now also runs incheck()wherevertraversalHydrationis set. Those are ascript/cross_fieldrule'scondition, at the top level and inside aconditional'sthen/otherwise. It judges the hop column against the target's field index (declared fields, injected columns andid) under the existingexpression-invalidrule, with no new code.os validate,os buildand the object save door.Census rows. objectql
federated-injected-column-readers.test.tsgets one row for the new engine use,engine.ts#resolvePredicateRelated :: referenceTargetOf()→caller-predicate.packages/core's[#22661]census is per file, and both caller files (engine.ts,validate-expressions.ts) already have rows.pnpm --filter @objectstack/core test:repopassed 82/82.Measurement (M, booted composition:
packages/qa/dogfood/test/rule-hop-undeclared-related-field.dogfood.test.ts)Fixture:
rrh_orderwith a lookupaccounttorrh_account(declaresstatus). It carries twoscriptrules:open_accountreadsrecord.account.status, which is declared, andnorth_onlyreadsrecord.account.region, which is not.45bec43cd9)69f76c6ef)PUT /meta/object/rrh_order_savedwith both rules200(saved)422 INVALID_METADATA, oneexpression-invalidissue atvalidation 'north_only', message opens "unknown fieldregiononrrh_account"open_account200200open_accountalone, open / closed account201/400with its own message201/400with its own message400: BOTH rules "could not be evaluated ('rrh_account' declares no 'region')"400: onlynorth_only, namingregion;open_accountabsent400:open_account's own message, plusnorth_only's refusalThe before run on
45bec43cd9had 3 of 4 pins red. The save door answered 200, and the stored copy's open-account write was refused withopen_account"could not be evaluated ... declares no 'region'". The control's red was the pin's own200vs201create status, corrected before the after run. The after run is 4/4, with the #24410 formula pin also green (7/7).Ablations (each through
scripts/ablation-replace.mjs,ablation-dist-preflight.mjsmarker present in dist, restored to the HEAD blob withgit diff HEADempty, then rebuilt with the marker absent from dist):named.deleteof the undeclared columns removed inresolvePredicateRelated). 3 engine pins red (engine-predicate-relationship.test.ts, the[#24468]block), and the booted sibling pin red.refuseUndeclaredHopField(..., 'rule')call removed). 3 lint pins red (validate-expressions.rule-one-hop.test.ts, and both LIT pins inruntime-gate.object-validation-writes.test.ts), and the booted save-door pin red.git status --porcelainclean, both markers absent from dist, booted pin 4/4.Pins
packages/objectql/src/engine-predicate-relationship.test.ts,[#24468]block, on the real engine:not_a_column;['id', 'type'].packages/objectql/src/validation/rule-relationship-traversal.test.ts,[#24468]block, on the binding contract: verdicts per rule; the bad rule names only its own field when the binding lists two; a fully undeclared reference still refuses.packages/lint/src/validate-expressions.rule-one-hop.test.ts, on the build:idandcreated_atadmitted;packages/lint/src/runtime-gate.object-validation-writes.test.ts,#24468block, at the save door:conditional→then;packages/lint/src/validate-expressions.test.ts: the#5017receiver census registers the refusal'sanalysislocal as plumbing.packages/qa/dogfood/test/rule-hop-undeclared-related-field.dogfood.test.ts: the booted pin above. Test-only; declared todomain:cliby the seat at review.Pin sweep. The whole-reference
undeclared-fieldverdict for a rule is pinned in two places, and both still hold:rule-relationship-traversal.test.tshands a single-rule binding by hand.engine-predicate-relationship.test.ts"never smuggles an UNDECLARED field" has one rule, whose own field is undeclared.No other test asserts the sibling refusal.
Other predicate surfaces that read through a lookup
Probed with
validateStackExpressions:requiredWhen) or an optionvisibleWhenthrough a lookup is already refused whole at save. Nothing hydrates there; the message says "a field-level predicate never reads the related record". This pass does not run there.conditionalrule'swhenis opted out of hydration by the existing walk (checkConditionalevaluates it unhydrated), so this pass does not judge it. See the acceptance notes.Verification (final head
69f76c6ef; merge oforigin/main959ee317bat0453ca518)turbo run build --filter=@objectstack/dogfood^... --filter=@objectstack/dogfoodexited 0 (66 tasks) on the merged tree. The 8 extra dists thatcheck:dual-build-cjs-loadsneeded also built, exit 0.pnpm --filter @objectstack/objectql test: 446 files, 8387 tests passed.pnpm --filter @objectstack/lint test: 146 files, 6885 tests passed on69f76c6ef. The first run, on0453ca518, failed one test: the receiver census'sanalysis, fixed in69f76c6ef.typecheck: objectql exit 0, lint exit 0 (both include theircheck:test-typecheckleg; theirtscexcludes tests), dogfood exit 0 (itstsc --listFilesincludes the new pin).pnpm --filter @objectstack/core test:repo: 6 files, 82 tests passed.node scripts/pm/dispatch-gates.mjs --commands(no paths) derived 76 commands. All 76 ran on0453ca518; 75 exited 0.check:dual-build-cjs-loadsexited 3 (PREREQUISITE NOT MET: 8 dists missing), was built for, and re-ran with exit 0.69f76c6ef(one lint test file), the derivation is identical. Re-ran:check:cross-package-test-inputs,check:test-source-alias,check:type-check-coverage,check:type-check-debt,check:nul-bytes,check:issue-citations,check:tier-file-adoption, and the three changeset gates. All exited 0.--ranreconciliation: 76 derived, 76 run, 0 NOT-MEASURED, 0 UNRUN.origin/mainmoved to7e58e0a27after the merge: onepackages/clitest budget, no overlap, not merged.Changesets
@objectstack/lintminor,fix(lint)!:, BREAKING (an accept-set narrowing), withClause-②: no (narrowing). Its ADR-0087 marker isnot-required (no-migration-prescription). It carries a FROM → TO and a "What to do".@objectstack/objectqlpatch,Clause-②: nowith no arm.Acceptance notes
conditionalrule'swhenthat reads through a lookup is not hydrated (checkConditional), and the lint opts it out of the traversal checks. Sowhen: record.account.status == 'x'saves clean, and it meets the bare id at evaluation. This is read from the code (the walk's own comment says it), not run as a failing probe. It is outside this card's two seams.objectExtensionsentry contributes atos build/os validate, as for the formula site (formula lint admits a read of a field the related object does not declare; the read then nulls every formula through that lookup, valid siblings included #24410's changeset says the same). The save door judges the merged object.Generated by Claude Code