Repository navigation
fix(objectql,lint)!: a conditional rule's when reads one hop through a reference, hydrated like a condition - #24650
Conversation
…a reference, hydrated like a condition WIP: engine hydrates when (collector + checkConditional), lint opts when into traversalHydration; pins in objectql and lint. Claude-Session: https://claude.ai/code/session_01AHeUBbe6S3JRawcpbLejXD Co-authored-by: Claude <noreply@anthropic.com>
…lookup Claude-Session: https://claude.ai/code/session_01AHeUBbe6S3JRawcpbLejXD Co-authored-by: Claude <noreply@anthropic.com>
…breaking narrowing) Claude-Session: https://claude.ai/code/session_01AHeUBbe6S3JRawcpbLejXD Co-authored-by: Claude <noreply@anthropic.com>
…sserts Claude-Session: https://claude.ai/code/session_01AHeUBbe6S3JRawcpbLejXD Co-authored-by: Claude <noreply@anthropic.com>
…nditional-when-hydration
📓 Docs Drift CheckThis PR changes 2 package(s): 3 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also name something this change touched. These are read-only:
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 64b57e1aeccf20cb9e37678654082a2934e39ccf && git checkout 64b57e1aeccf20cb9e37678654082a2934e39ccf
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin f89d29e382e7d50cfe1b2a98742ac1c3d436cd26 a8614e4116d9b89950a393023a7d44f1d4a96b79 && git checkout -B drift-repro f89d29e382e7d50cfe1b2a98742ac1c3d436cd26 && git merge --no-ff a8614e4116d9b89950a393023a7d44f1d4a96b79
node scripts/docs-audit/affected-docs.mjs --json f89d29e382e7d50cfe1b2a98742ac1c3d436cd26
|
席位核对 · ACCEPT (seat half) ·
|
Contract reviewServed-tier: ① Derived judgmentsFace and inputs.
② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS |
Fixes #24512
Clause-②: no (narrowing) (the save door newly refuses a
whenthat reads a second hop or an undeclared related column, which today saves and then refuses every write it guards; a narrowing owes oneCONTRACT_REVIEW_TIERreview of record before the queue)Dispatched by the
domain:engineseat 2 PM (claim 6275835421,session_01AHeUBbe6S3JRawcpbLejXD). The developer ran as that session's subagent. The route is triage 6265543836's: hydratewhen.What changes
Engine (
packages/objectql,validation/rule-validator.ts). No change inengine.ts: the engine's related read already takes its input from the collector below.collectPredicateRelationshipsnow collects aconditionalrule'swhen, at every depth, beside the rules it wraps. So the engine preloads awhen's hop through the same loader as a condition's. It uses the same system authority, the same projection, and PR 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's per-rule handling of undeclared columns.checkConditionalevaluateswhenon the scoperesolveTraversalScopedecides, the functioncheckPredicatecalls. The hydrated copy belongs to thewhenalone. The branch gets the untouched context and decides its own scope, so a branch comparing the stored id still sees the id.when: a second hop, a reference read both through it and as a value, an undeclared related column, or an empty, unresolved or unreadable reference. That refusal goes through one new builder,traversalScopeRefusal, whichcheckPredicatenow uses too. It answerscode: 'rule_violation'withconstraint: { rule, reason: 'unevaluable', fault }.checkPredicate's message and log line are byte-identical to before.whenthat faults on a delete's reference cleanup getsreferentialClearRefusal's text, as a condition does.Lint (
packages/lint,validate-expressions.ts). This is the cross-domain rider the claim declares todomain:spec.whenand every nested predicate passtraversalHydration. The existing hop checks now judge awhen: second hop, both-ways read, stored-metadata-body hop, and undeclared hop column. They run under the existingEXPRESSION_INVALIDrule and are located at thewhenslot. No new rule, code or message.RulePredicate.slotlost its only reader and is removed.Measurement
Write time
The fixture is the real
ObjectQLengine over an in-memory driver double. AconditionalhaswhenW,thenandotherwiserules that both refuse an amount over 10, and the insert hasamount: 50. So "then" or "otherwise" names the branch that was judged.rule-validator.tsrestored from1e8b6284c(this PR's base);engine.tsis unchanged by this PR.record.account.type == 'partner'No such key: typeNo such key: typethenrecord.account != null && record.account.type == 'partner'otherwiseNo such key: typerecord.account.?type.orValue('') == 'partner'otherwiseotherwise(wrong branch, silently)thenhas(record.account.type) && record.account.type == 'partner'otherwiseotherwise(wrong branch, silently)thenrecord.account.owner.region == 'x'No such key: ownerNo such key: ownerrecord.account.region == 'x'(undeclared)No such key: region'crm_account' declares no 'region'No such key: region'crm_account' declares no 'region'record.account != null(the guard)otherwiseotherwisethenthenThe "before" refusal text for every row was "The when-predicate reads 'type', which this object does not declare — fix the rule's condition, or declare the field". That sentence points the author at the wrong object.
The runtime narrowing. Rows 2 to 4, empty-reference column: a write that
otherwisejudged before is refused now. Thewhenis judged as aconditionreading through an empty reference has been judged since hydration landed for conditions: the verdict does not depend on which CEL operator is written. The refusal names the guard, which is a separatewhen: record.account != nullwith this rule nested as itsthen. This was not in the claim's measurement assumptions; see the deviations.Save door and build
These run
validateStackExpressions, the passos validate,os buildand the object save door run. The fixtures passObjectSchema/ObjectStackSchemavalidation.when1e8b6284c)record.account.region == 'north'(target declares noregion)EXPRESSION_INVALIDatvalidation 'gate' when, "unknown fieldregiononhop_account"record.account.owner.email == 'x'record.account != null && record.account.status == 'active'record.account.status == 'active'(declared)record.account != null(the guard)The "before" column is the new pins run on the unfixed tree: three red, with
expected [] to deeply equal [ "object 'hop_order' · validation 'gate' when" ]. The stored-metadata-body hop takes the same opt-in and is not separately pinned.Exposure in the shipped tree. Measured at
1e8b6284cwithgit grepoutside tests: zerowhenreads through a reference. The control is the same regex withconditionin place ofwhen, which finds 8 lines, among themexamples/app-showcase/src/data/objects/invoice.object.ts:191. The onlywheninexamples/isrecord.status == 'churned'.Pins
packages/objectql/src/validation/rule-relationship-traversal.test.ts,[#24512]block, on the evaluator:whenhop, and nothing for the guard spelling;thenandotherwiseare each picked on the related row, at the top level and nested;code+constraint.rule/reason/fault) while a sibling keeps its own verdict;packages/objectql/src/engine-predicate-relationship.test.ts,[#24512]block, on the real engine:VALIDATION_FAILEDenvelope and per-fieldcode;['id', 'type'];whenreading an undeclared column refuses alone and never enters the read.packages/lint/src/validate-expressions.rule-one-hop.test.ts,[#24512]block, on the build: the three refusals at the top-level and nestedwhenslots, the declared read admitted, and the guard control.packages/lint/src/runtime-gate.object-validation-writes.test.ts:#22042per-slot pin used to assert the opt-out ("OFF for a nestedwhen"). It is rewritten to assert the opt-in at both slots, top level and nested.#24512block at the save door: a target from another package, a nestedwhen, and the control, all assertingrule: EXPRESSION_INVALID.Ablations
Each leg went through
scripts/ablation-replace.mjs. The anchor hit once and the mutation landed (blob changed). The file was restored to the HEAD blob withgit diff HEADempty. The subjects are imported by relative path fromsrc/, so nodist/is involved.addFrom(r.when)deleted from the collector: 5 red, the two collector pins and the three engine pins. Pin (a) on the real engine shows the card's defect:Validation rule 'partner_gate' could not be evaluated (runtime: No such key: type).checkConditionalevaluatingctx.mergedinstead of the hydrated copy: 6 red (both branch pins, nested, branch-isolation, engine branch pin, engine sibling pin).whenscope refusal replaced byreturn null: 4 red (second hop, undeclared, empty reference, engine sibling).whencleanup text replaced bynull: 1 red (the cleanup pin).when/ the nestedwhen: 5 red / 4 red.Verification
Final head
a8614e4116, a merge oforigin/main14ccb1da9. The incoming commits areservice-automationand aspec/uiexport, and they touch none of this diff's files.pnpm install --frozen-lockfile: exit 0.pnpm turbo run build --concurrency=2: 77/77 tasks, exit 0.pnpm --filter @objectstack/objectql exec vitest run --project local: 447 files, 8432 tests passed, exit 0.pnpm --filter @objectstack/lint exec vitest run: 148 files, 6932 tests passed, exit 0.typecheck: objectql exit 0 and lint exit 0. Both include theircheck:test-typecheckleg.node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack(no paths) derived 70 commands. All 70 ran ona8614e4116and exited 0.--ranreports 70 derived, 70 run, 0 NOT-MEASURED, 0 UNRUN.e5a2eecb0had three non-zero exits, each resolved before the final run:check:error-code-casing(exit 1): the new test literals assertedcode: 'rule_violation'without thefieldkey that marks them field-addressed. Fixed in9b80c99f6.check:engine-split-ratio(exit 2): the shallow-clone refusal. Deepened withgit fetch --shallow-since=2026-07-14.check:dual-build-cjs-loads(exit 3, PREREQUISITE NOT MET): missing dists. Ran after the full build.test:repowas not run locally. It is CI-owned, and this diff touches norepo-project test.Changeset
.changeset/24512-conditional-when-hydration.md:@objectstack/objectqlminorand@objectstack/lintminor;fix(objectql,lint)!:, BREAKING (an accept-set narrowing at the save door and at write time),Clause-②: no (narrowing);not-required (no-migration-prescription), accepted bycheck-adr-0087-registration;Deviations from the dispatch
@objectstack/objectqlisminorBREAKING, notpatch. The write-time probe above measured a narrowing on that face. A write on an empty reference under a null-safewhen(.?,has(), or an inline!= nulltest of the same reference) was judged byotherwisebefore. Now it is refused. Precedent for an objectql write-time narrowing: the8d8f965entry,Clause-②: no (narrowing), minor.otherwiseon an empty reference (now refused, as above).whenascheckPredicatehydrates a condition, with the same helpers and no new resolver.packages/qa/dogfoodisdomain:cli). The envelope is asserted at the engine (code: 'VALIDATION_FAILED'plus per-fieldcodeandconstraint) and at the save-door pass (rule: 'expression-invalid'). The HTTP statuses (400on the data door,422 INVALID_METADATAon the save door) are the existing mappings and were not re-measured on a booted stack here.Acceptance notes
whennow follows a condition's rules for an empty reference. No single-expression spelling skips a rule while the reference is empty. The guard is its ownwhen, as the refusal says. This is the platform's existing stance for conditions, now applied towhen.packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.tscarries nowhen-hop pin. The save-door pass is pinned in lint's runtime-gate test, which is the body the door runs. Not added here.pnpm --filter @objectstack/core test:repowas not run.packages/core's per-file census already has rows for both edited source files, and CI'sTest Coreruns it.Generated by Claude Code