Repository navigation
fix(objectql): a validate() preview binds the master-detail header the write binds (#22474) - #22518
Conversation
…e write binds The preview's one evaluateValidationRules call bound no `parent`, so a field requiredWhen reading the header refused, as unevaluable, rows the insert and the update admit. It now binds `parent` from the judged view (payload FK first, then the stored row) and, for a repointing update, `previousParent` from the stored row, under the write's own gate (hasParentScopedRequiredWhen). Unlike the write, which reads the header elevated, the preview asks the read door first under the caller's context: a header the caller cannot read binds as a missing one, and a header column is kept only where the read door served the caller that very value (servedAsStored), so a hidden or masked column is judged as empty. A parity test reads engine.ts and fails when a write-side evaluateValidationRules call passes an input the preview's call does not. Claude-Session: https://claude.ai/code/session_01Bw3y2DWhT9RPnrmDsNqEVG Co-authored-by: Claude <noreply@anthropic.com>
…etail header Claude-Session: https://claude.ai/code/session_01Bw3y2DWhT9RPnrmDsNqEVG Co-authored-by: Claude <noreply@anthropic.com>
…the driver double The driver double's find carried a read counter backed by a Map, which the objectql-double-limit gate's probe stubs, so the double could not be judged. The control now counts header reads with an engine middleware, which sees every read of the header object under any context. Claude-Session: https://claude.ai/code/session_01Bw3y2DWhT9RPnrmDsNqEVG Co-authored-by: Claude <noreply@anthropic.com>
…ing them check:query-options-erasure counts an any-cast on engine read options; the preview's caller read of the header now carries EngineQueryOptions, and the test's reads and writes pass their context without a cast. Claude-Session: https://claude.ai/code/session_01Bw3y2DWhT9RPnrmDsNqEVG Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bw3y2DWhT9RPnrmDsNqEVG Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift Check1 anchor(s) derived from 1 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 — 17 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 26a2c447a4701f3aaf6d8fbc4256912f5f1ff7d2 && git checkout 26a2c447a4701f3aaf6d8fbc4256912f5f1ff7d2
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 446c8b2a6420a61a2862e6f5140dda71a53316d1 0958e192acecc92da47ea018f0c8eec039c9f72d && git checkout -B drift-repro 446c8b2a6420a61a2862e6f5140dda71a53316d1 && git merge --no-ff 0958e192acecc92da47ea018f0c8eec039c9f72d
node scripts/docs-audit/affected-docs.mjs --json 446c8b2a6420a61a2862e6f5140dda71a53316d1 |
Contract reviewServed-tier: Inputs: card #22474 (body; triage 6080477962; unlock 6082537339; PM claim 6084070287; os-dev-report 6085563448), PR #22518 (body, the 3-file list, the one bot comment 6085547392, zero reviews), the net diff of the head against its merge base with Check-runs on the head, as read at 2026-10-09T17:17Z (33 runs): 26 ① Derived judgmentsAccept-set and public-surface changes the diff implies, each named right or wrong.
Join: at my read the branch is 1 commit behind ② Semver level
Text: the body says what the engine does (①-1, ①-3, ①-5) — the two-door read, the served-as-stored rule, the elevated write read "kept to the caller's organization" (true of ③ Boundary flagsDev deviations (os-dev-report 6085563448), each answered:
PR-side flags: the Docs Drift Check (6085547392) lists nothing and is not a clean bill; the hand re-read in ①-11 found no page or changeset this diff falsifies. The PR is draft with no auto-merge; its 3 files touch no governed surface; head repo equals base repo; 712 changed lines; zero reviews; the Implemented-by: VERDICT: PASS — every accept-set and surface change the diff implies is named right above: the preview binds Generated by Claude Code |
Fixes #22474
Clause-②: yes
What was wrong
ObjectQL.validate()(the preview behindvalidateDataand the import dry run) made oneevaluateValidationRulescall and bound no master-detail header. Every write binds it: the insert passesparent: insertParentForRow?.(rows[i]), the by-id update passesparent: roWhenParentandpreviousParent: roWhenPreviousParent, and the bulk update passesparent: parentForRow?.(row)andpreviousParent: previousParentForRow?.(row). So a fieldrequiredWhen: "parent.status == 'sent'"faulted in the preview (Unknown variable: parent) and the preview refused the row asrule_violation/unevaluable, in both modes, while the write admitted it under a draft header.Measured before the change: this PR's test file, run with
engine.tsput back to itse148ca9842blob (5ca77a148de1), is 17 red of 21. The 4 green are the no-header-read control, the cannot-read pin (it holds trivially when the preview refuses every such row), and the two parity floor cases. The preview answereddescription: rule_violationfor an insert under a draft header and under a sent header, for an update with a stored line'sidunder either header, and for the import dry run (throughObjectStackProtocolImplementationandrunImport) of a created and of a matched line. The real insert, update and import admitted the draft-header rows and refused the sent-header rows withrequired. The parity case listed exactly the two missing inputs:parent(3 write calls) andpreviousParent(2 write calls).The change (
packages/objectql/src/engine.ts)validate()resolves the header per row from the judged view, the write's own payload-FK-first reading (masterIdOf): the row's FK on an insert, and on an update the patch's FK, else the FK of the stored row that objectql: an update-modevalidate()preview (an import dry run of a matched row) reads no stored row, so a rule that reads an omitted column refuses a row the real update admits #22445's change already reads. A repointing update also binds the stored row's own header aspreviousParent, for the ADR-0113 pre-check, exactly when the write resolves it (repointsMaster).requiredWhenreadingparent(hasParentScopedRequiredWhen, the insert's gate and therequiredWhenhalf of the update's gate) reads a header. Every other object's preview reads none.resolvePreviewParents). The write reads the header elevated (resolveMasterDetailParent(s)underreferenceCheckContext). The preview runs none of the write's gates, so it reads as objectql: an update-modevalidate()preview (an import dry run of a matched row) reads no stored row, so a rule that reads an omitted column refuses a row the real update admits #22445's stored-row read does:findfor the batch, decides whether the header exists for this caller. A header it does not return binds as a missing header binds (null), so the rule refuses as unevaluable whatever the header holds. A failed read binds every header as missing, as the write's own failed header read does, and logs atwarn.resolveMasterDetailParents) runs only for the headers read 1 returned.servedAsStored). The header is then materialised over the master's declared fields, so a hidden or masked column readsnull: judged as empty.servedAsStored(named in objectql: an update-modevalidate()preview (an import dry run of a matched row) reads no stored row, so a rule that reads an omitted column refuses a row the real update admits #22445's ACCEPT as riding the next PR that touches this spot) is moved back ontomergeReadContext;servedAsStored's docblock now names its second caller.packages/core/src/utils/import-runner.tsis not touched: the dry run already sends the row's FK (create) and the matched record'sid(update), which is everything the preview needs.Pins (
packages/objectql/src/validate-preview-parent.test.ts, 21 cases)id: admitted under a draft header, refused withrequiredunder a sent header; the real insert and by-id update give the same verdict.requiredWhenreads no header in either mode (an engine middleware counts every header read under any context); positive control: the parent-scoped line does read one.statushidden, and one served it masked, get one verdict whatever the header holds, and it is the empty-column verdict. Control: a caller who reads the header in full gets the write's verdict in both modes.engine.tsitself (below).How the parity pin finds the call sites
It parses
engine.tswith the TypeScript compiler API, finds every call whose callee is the identifierevaluateValidationRules, and attributes it to its enclosing class method: the one invalidateis the preview, every other is a write (today four:insert's insert call,update's by-id call and its two bulk calls). A call's inputs are the keys of its options object literal, including the keys of an object literal spread into it (...(c ? { k } : {})). An options argument it cannot see into fails the test rather than passing it. It asserts: exactly one preview call; at least four write calls covering both modes; every key a write call passes is passed by the preview; the preview passes no key no write passes.Reverse verification and ablations
Each leg through
scripts/ablation-replace.mjs(anchor must hit once, blob change and restore proven againstHEAD), inside a script with its own EXIT/INT/TERM restore trap. Run on head57c3ce9f57and again on the merged head0958e192ac(engine.tsblobdf2300664b3cin both), with identical red sets. The subject is imported as source (./engine.js), so nodist/is in the resolution path.parent: undefined,previousParent: undefinedpreviousParent: undefinedonlyreferenceCheckContext(elevated)servedAsStored(...)replaced bytruetruehasParentScopedRequiredWhengate replaced bytrueengine.tslineparent/previousParentkeys removed from the preview callA first P3 attempt used an anchor its own replacement contained; the tool refused it before any run (anchor count did not drop), and it was re-run with a distinct anchor. An earlier G1 leg ran while a sibling gate's full build had removed
packages/metadata-protocol/dist, so its suite never loaded; it is discarded, and all nine legs above were re-run together on both heads.Verification (head
0958e192ac,origin/main446c8b2a64merged in, no conflict, no file of this PR touched by it)pnpm --filter @objectstack/objectql exec vitest run --project local --maxWorkers=2: 392 files, 7737 tests passed (also on57c3ce9f57before the merge, same counts).pnpm --filter @objectstack/objectql typecheck: exit 0 on0958e192ac; the new test file is in thetsconfig.test.jsonprogram (--listFiles) and adds no error to the pinned test-typecheck debt.node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands(68 commands): all 68 exit 0, each exit captured before any pipe;--ranreconciliation: 68 derived, 68 run, 0 NOT-MEASURED. On the pre-merge head two gates were red and are fixed here:check:objectql-double-limit(the test double'sfindcarried a read counter its probe could not drive; the control now counts reads with an engine middleware) andcheck:query-options-erasure(anas anyon the header read's options, nowEngineQueryOptions).eslint --no-inline-config --format jsonover the two changed source files, 2 files, 0 errors, 0 warnings. Population read from the config (calculateConfigForFile: both.tsfiles linted, the changeset ignored); the config enables no type-aware linting (noparserOptions.project), so the diff cannot move the verdict of an untouched file.Acceptance notes
hxinserts a line under it; the write admits it whilehxis draft and refuses it withrequiredwhilehxis sent, so the write's verdict depends on a header that caller cannot read. This is the documented design ofresolveMasterDetailParent(the caller's right to write the detail is settled before the header is read; the tenant wall is kept). The preview keeps the stricter rule, so for such a caller the preview and the write can answer differently, which the changeset states.readonlyWhenstrip, so the update's judgement of the FK's ownreadonlyWhenlock (settleMasterDetailLandingstep 1) is not run; a repoint that lock would take back out is judged against the header the patch names.visibleWhenthat readsparent(the card's "likely sibling") is unchanged: no write bindsparentfor option gates either (evaluateOptionVisibilitytakes no header), so, read from source and not measured here, preview and write already agree there (both refuse the faulting pick).parentandpreviousParent.Generated by Claude Code