Skip to content

fix(objectql): a validate() preview binds the master-detail header the write binds (#22474) - #22518

Merged
objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/issue-22474-preview-binds-parent
Oct 9, 2026
Merged

objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/issue-22474-preview-binds-parent

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #22474
Clause-②: yes

What was wrong

ObjectQL.validate() (the preview behind validateData and the import dry run) made one evaluateValidationRules call and bound no master-detail header. Every write binds it: the insert passes parent: insertParentForRow?.(rows[i]), the by-id update passes parent: roWhenParent and previousParent: roWhenPreviousParent, and the bulk update passes parent: parentForRow?.(row) and previousParent: previousParentForRow?.(row). So a field requiredWhen: "parent.status == 'sent'" faulted in the preview (Unknown variable: parent) and the preview refused the row as rule_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.ts put back to its e148ca9842 blob (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 answered description: rule_violation for an insert under a draft header and under a sent header, for an update with a stored line's id under either header, and for the import dry run (through ObjectStackProtocolImplementation and runImport) 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 with required. The parity case listed exactly the two missing inputs: parent (3 write calls) and previousParent (2 write calls).

The change (packages/objectql/src/engine.ts)

packages/core/src/utils/import-runner.ts is not touched: the dry run already sends the row's FK (create) and the matched record's id (update), which is everything the preview needs.

Pins (packages/objectql/src/validate-preview-parent.test.ts, 21 cases)

  • (a) Insert, and update with a stored line's id: admitted under a draft header, refused with required under a sent header; the real insert and by-id update give the same verdict.
  • (b) A repoint from a draft onto a sent header is refused, as the write refuses it (the stored row complied under the header it leaves); a legacy line under a sent header rests through a repoint onto another sent header, as on the write.
  • (c) Control: an object with a master-detail relation and only a row-scoped requiredWhen reads no header in either mode (an engine middleware counts every header read under any context); positive control: the parent-scoped line does read one.
  • (d) Security. A caller whose read scope excludes the header gets one verdict whatever the header holds, equal to the verdict for a header id that names no row (insert and update). A caller served status hidden, 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.
  • (e) The import dry run through the protocol, created line and matched line (an edit, and a repoint onto a sent header), answers what the real import does.
  • (f) Parity, read from engine.ts itself (below).

How the parity pin finds the call sites

It parses engine.ts with the TypeScript compiler API, finds every call whose callee is the identifier evaluateValidationRules, and attributes it to its enclosing class method: the one in validate is 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 against HEAD), inside a script with its own EXIT/INT/TERM restore trap. Run on head 57c3ce9f57 and again on the merged head 0958e192ac (engine.ts blob df2300664b3c in both), with identical red sets. The subject is imported as source (./engine.js), so no dist/ is in the resolution path.

leg mutation red green
A1 preview binds parent: undefined, previousParent: undefined 15: all of (a), (b), (e), the hide/mask pins and the full-reader control in (d) (c) both, the cannot-read pin, the 3 parity cases
A1b previousParent: undefined only 2: the repoint pin in (b), the import repoint in (e) 19
S1 the visibility read under referenceCheckContext (elevated) 3: the cannot-read, hide and mask pins 18, the full-reader control included
S2 servedAsStored(...) replaced by true 1: the mask pin 20, the hide pin included
S3 the whole column filter replaced by true 2: the hide and mask pins 19
G1 the hasParentScopedRequiredWhen gate replaced by true 1: the no-header-read control 20
P1 a new key added to the insert's write call only 1: "every key a write call passes, the preview passes", naming the key and engine.ts line 20
P2 parent / previousParent keys removed from the preview call 16: A1's 15 plus that parity case 5
P3 a key added to the preview call only 1: "the preview passes no input that no write passes" 20

A 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/main 446c8b2a64 merged 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 on 57c3ce9f57 before the merge, same counts).
  • pnpm --filter @objectstack/objectql typecheck: exit 0 on 0958e192ac; the new test file is in the tsconfig.test.json program (--listFiles) and adds no error to the pinned test-typecheck debt.
  • Gates from node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands (68 commands): all 68 exit 0, each exit captured before any pipe; --ran reconciliation: 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's find carried a read counter its probe could not drive; the control now counts reads with an engine middleware) and check:query-options-erasure (an as any on the header read's options, now EngineQueryOptions).
  • Lint, narrowed: eslint --no-inline-config --format json over the two changed source files, 2 files, 0 errors, 0 warnings. Population read from the config (calculateConfigForFile: both .ts files linted, the changeset ignored); the config enables no type-aware linting (no parserOptions.project), so the diff cannot move the verdict of an untouched file.

Acceptance notes

  • The write reads the header elevated (finding, reported per the dispatch). Measured with a throwaway test on this branch: a caller whose read scope excludes header hx inserts a line under it; the write admits it while hx is draft and refuses it with required while hx is sent, so the write's verdict depends on a header that caller cannot read. This is the documented design of resolveMasterDetailParent (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.
  • Named limit kept: the preview still runs no readonlyWhen strip, so the update's judgement of the FK's own readonlyWhen lock (settleMasterDetailLanding step 1) is not run; a repoint that lock would take back out is judged against the header the patch names.
  • An option visibleWhen that reads parent (the card's "likely sibling") is unchanged: no write binds parent for option gates either (evaluateOptionVisibility takes no header), so, read from source and not measured here, preview and write already agree there (both refuse the faulting pick).
  • The parity pin compares option keys, not values; the behavioural pins hold the values of parent and previousParent.

Generated by Claude Code

claude added 5 commits October 9, 2026 15:45
…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>
…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>
@github-actions github-actions Bot added the size/l label Oct 9, 2026
@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Oct 9, 2026
@github-actions

github-actions Bot commented Oct 9, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

1 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
  • 1 anchor(s) matched too much of the corpus to be a work list: ObjectQL (symbol, 72 pages)
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 17 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 446c8b2a6420a61a2862e6f5140dda71a53316d1 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 26a2c447a4701f3aaf6d8fbc4256912f5f1ff7d2 — the merge of head 0958e192acecc92da47ea018f0c8eec039c9f72d into base 446c8b2a6420a61a2862e6f5140dda71a53316d1, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 0958e192acecc92da47ea018f0c8eec039c9f72d
Local-runs: none

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 origin/main (446c8b2a64; 701 added / 11 deleted, 3 files, equal to the PR's own file list; #22445's 35ef501e13 is an ancestor of the head), and the check-runs on the head. The two records on PR #22471 (6080151175 FAIL, 6081452811 PASS) were read as the precedent for how a preview treats a column the caller cannot read in full. origin/main source was read with git show / git grep to judge the write-side call shapes and consumers (engine.ts, rule-validator.ts, master-detail.ts, declared-fields.ts, import-runner.ts, protocol.ts, verify/handle.ts, protocol.zod.ts, the pending changesets); nothing was built, run or re-run. Reading time: 2026-10-09T17:21Z.

Check-runs on the head, as read at 2026-10-09T17:17Z (33 runs): 26 success, 3 skipped (Build Docs, Console Pin Gate, Packed-tarball smoke (opt-in), none required), 4 in_progress — Lint & Repo Gates, Test Core (1/6), (3/6), (4/6). The seven required contexts: Lint & Repo Gates IN PROGRESS; TypeScript Type Check success (its four Type Check · sub-jobs — source gates, consumer gates, debt ledger, workspace — all success, so check:api-surface and the spec artifact gates are CI-confirmed); Test Core 3 of 6 shards success, 3 in progress; Dogfood Regression Gate success (all three shards and the summary); Build Core success; Temporal Conformance (live PG + MySQL) success; Governed Surface Queue Guard success. Check Changeset success. An in-progress run is recorded as such; none is read as a pass, and Lint & Repo Gates is the job that carries check:adr-0087-registration, check:changeset-no-major, check:empty-changeset, check:durability-log-level, check:objectql-double-limit and check:query-options-erasure — the dev's local exit codes for those are not an input to this record.

① Derived judgments

Accept-set and public-surface changes the diff implies, each named right or wrong.

  1. The preview's accept set widens exactly where the three writes admit — RIGHT. For an object with a parent-scoped requiredWhen, validate() now binds parent per row from the judged view through the write's own reader masterIdOf(fk, null, view) (engine.ts:3510 on main: the FK in hand, else nothing; a scalar string or finite number only): the row's FK on an insert, and on an update the patch's FK, else the stored row's, because judgedViews[i] is { ...storedRows[i], ...row }. That is the insert's insertParentForRow (:14293, resolveMasterDetailParents(schema, null, rows) reads each row's own FK), the by-id update's roWhenParent and the bulk update's parentForRow (masterIdOf(fk, landing.view ?? payload, prior), payload first). previousParent is bound exactly when the write resolves roWhenPreviousParent / previousParentForRow: a stored FK in hand and a landing FK that differs (repointsMaster, :15787); otherwise undefined, which evaluateValidationRules reads as "same header as parent" (rule-validator.ts:3317). Pins (a) and (b) hold both directions against the real insert and by-id update, and (b)'s legacy-row case holds the VALUE of previousParent apart from parent (merged under the sent header requires; the pre-check under the stored row's own sent header shows a pre-existing violation and lets the row rest — rule-validator.ts:3353-3355); (e) holds it through the protocol and runImport, created and matched rows. A repoint whose prior header cannot be resolved binds previousParent: null, so the pre-check faults and the pre state reads as compliant (:3349-3351), exactly as the write's own null from a failed elevated read does.

  2. The gate hasParentScopedRequiredWhen is exact for the rule evaluation — RIGHT. In evaluateValidationRules the parent root is bound for the field-level requiredWhen block and nothing else (rule-validator.ts:3314-3322; the options docblock at :420-429 says it is deliberately NOT bound for the object-level script / cross_field / conditional rules). The update writes resolve a header under a wider gate (wantsParentBinding = hasParentScopedReadonlyWhenInPayload || hasParentScopedRequiredWhen, :15757), but the extra arm feeds the readonlyWhen strip, which the preview does not run (the named limit, unchanged since objectql: an update-mode validate() 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). So no rule the preview evaluates reads a header the preview declines to resolve. Control (c) pins zero reads of the header object through the read door for an object with only a row-scoped rule, with a positive control.

  3. Two doors, under the caller's access, then the write's elevated read for the served ids only — RIGHT, and it is the shape the precedent's PASS record (6081452811 ①-8) accepted for the stored row, carried to the header. Door 1 is this.find(master, { where: { id: { $in } }, context }) under the caller's own context, one query for the batch; door 2 is resolveMasterDetailParents under referenceCheckContext ({ ...context, isSystem: true }, :16734 — tenant kept, [finding] master-detail parent binding reads the header with no tenant — parent.* predicates leak another org's header fields; the dangling-reference audit is blind to cross-org references #19837), fed { [fk]: id } for the served ids, which already materialises every header over the master's declared fields (:8700). A column is kept only where hasOwnProperty(served, key) && servedAsStored(served[key], value), then the kept subset is materialised again (materializeParentHeader → materializeDeclaredFields, which adds null for every declared key the subset lacks and strips nothing, declared-fields.ts:193). Walked on main's read door (find(), :12612-12654 on the head): a hidden column (key deleted by the mask) is not kept and reads null; a partially masked one (key kept, value replaced) compares unequal and reads null; a secret column is masked on BOTH door reads (maskSecretFields runs after the hooks with no isSystem exemption), so mask equals mask and is kept — which is the value the write's own resolver judges too, since it reads through the same find; a file column is expanded on both reads (resolveFileReferences, no raw opt-out on either side, unlike the stored-row read that asks raw because its elevated twin is a raw driver read), so a reference the caller can expand compares equal and one it cannot stays an id token on the served copy and reads null — the fail-closed direction. So the verdict never depends on a header value the caller could not have read in full; a header the caller cannot read at all binds as null, and the rule refuses it as unevaluable whatever the header holds. Pins (d): the cannot-read caller's verdict is deep-equal to the verdict for a header id that names no row, in both modes; the hide and mask shapes each answer one verdict across stored draft / sent, with the precondition asserted on the read door (served.status === status is false), and that verdict is the empty-column one; the full reader gets the write's verdict in both modes.

  4. A failed door-1 read binds every header as missing and logs at warn — RIGHT. It mirrors the write's own resolver (resolveMasterDetailParents catches, warns "parent stays unbound", returns unbound) rather than objectql: an update-mode validate() 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, which propagates. The two differ because the write's posture IS the contract here: an unbound header refuses the row as unevaluable naming parent, on the write and on the preview alike, so the failure reaches the caller as a refusal and is not an invented admission; the catch logs, so it is outside the read-seam invention rule's shape. Lint & Repo Gates carries check:durability-log-level and was in progress at my read.

  5. For a caller served less than the write reads, the preview and the write can disagree, in the stricter direction only — RIGHT, as the only non-disclosing shape (the same judgment as 6081452811 ①-10). The write reads the header elevated behind its own gates; the preview runs none of them, so it judges null where the write judges the stored value, and may refuse (cannot read: unevaluable) or admit (hidden or masked column: null == 'sent' is a clean false) a row the write answers the other way. The write remains the gate: a dry-run verdict is reported again by the real import's row report. Disclosed in the engine docblock, the inline comment and the changeset's last sentence of its third bullet.

  6. Reach of the widening, measured on main — engine.validate() is called by the protocol's validateData relay (protocol.ts:13608-13613, forwards mode and context), through it by the import dry run (import-runner.ts:717, previewVerdict forwards the row's own context; sends the row's FK on a create and { ...data, id: existing.id } on a match, :1035-1036), and by @objectstack/verify's handle.validate (verify/handle.ts:364, ql.validate(object, record, { mode, context }), a published package the objectql: an update-mode validate() 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 records did not name). Each gets the same widening through the dependency; none changes shape; no REST route serves validateData. Leaving import-runner.ts untouched is RIGHT: the dry run already carries everything the preview reads.

  7. Public surface — RIGHT: resolvePreviewParents is private; EngineQueryOptions is an existing import (engine.ts:10); no export, key, option or error code is added, removed or renamed; check:api-surface sits in Type Check · consumer gates (success).

  8. The parity pin closes the family the triage named — RIGHT. It parses engine.ts from the same package (new URL('./engine.ts', import.meta.url), not an escaping path), attributes every evaluateValidationRules call to its enclosing method, reads option keys through conditional spreads, fails on an options argument it cannot read, and asserts one preview call, at least four write calls spanning both modes (on main: :14320 insert, :15908 by-id, :16211 bulk with prior, :16227 bulk without), every write key passed by the preview and no preview-only key. It compares keys, not values; the behavioural pins hold the values. typescript is a declared devDependency of objectql; the @objectstack/core and @objectstack/metadata-protocol imports are declared dependencies with precedent. The dev's P1/P2/P3 legs are the dev's evidence, not an input here.

  9. The docblock move — RIGHT: it closes 6081452811 ①-13 (the stray one-liner above servedAsStored is back on mergeReadContext, and servedAsStored's own docblock now names its second caller).

  10. The card's "likely sibling" — an option visibleWhen that reads parent — left unchanged — RIGHT. evaluateOptionVisibility (rule-validator.ts:2992) takes no header on main, so no write binds parent for an option gate either; the pending changeset 22157-option-visible-when-parent.md makes the build and the object save door refuse such a predicate and says the refusal lifts only when the runtime binds parent for options. Preview and write already agree there (both fault, and since objectql: evaluateOptionVisibility continues on a predicate fault, so a select option's server-side gate admits the write — fail open or fail closed, under ADR-0089 (the runtime half of #22394) #22402 both refuse). Read from source, as the PR says; not measured.

  11. Spec and docs text — nothing falsified, no edit owed. ValidateDataRequest.mode and the ValidateDataResponse JSDoc (protocol.zod.ts:2226-2253) say nothing about the header and their readonlyWhen sentence stays true; content/docs/data-modeling/import-mappings.mdx:176-177 ("the verdict comes from the engine's own write-path validation") stays true and becomes more so; the pending 22445 and 22402 changesets carry no sentence about the header; skills/ and .claude/skills/ carry none. The Docs Drift Check (6085547392) lists nothing and says so is not a clean bill; the hand re-read above is the compensating read.

Join: at my read the branch is 1 commit behind origin/main (4e9fe9ff6a); the 52 files main gained since the merge base and the PR's 3 files are disjoint (comm over the two --name-only lists is empty), and none of them is under packages/objectql, import-runner.ts or rule-validator.ts. The queue's merge ref judges the join.

② Semver level

Clause-②: yes is declared in the PR body and in .changeset/22474-preview-binds-parent.md, with no (widening) / (narrowing) arm — zero arms is within the closed pair's "at most one". The widening is real (rows the preview refused as unevaluable and the write admits are now admitted), so the floor is minor; the changeset bumps @objectstack/objectql at minor. Nothing is removed, renamed or narrowed — no key, no export, no error code — so no ADR-0087 disposition marker is owed; Check Changeset is success on the head, and check:adr-0087-registration, check:changeset-no-major and check:empty-changeset ride in Lint & Repo Gates (in progress at my read). @objectstack/core, @objectstack/spec and @objectstack/verify publish nothing from this diff (no source of theirs changes; the behaviour arrives through the objectql dependency), so no changeset line is owed for them. Level: RIGHT.

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 referenceCheckContext), and the divergence for a caller served less. One clause is overbroad: "Only an object that declares a field requiredWhen reading parent reads a header, as on the write" — exact for the insert and for every header the rule evaluation reads; the update write also reads a header when the payload touches a parent-scoped readonlyWhen, for the strip the entry's "Unchanged" bullet says the preview does not run. The entry read whole is true; the clause alone is not. A precision note the seat may tighten on the next changeset touch ("…reads a header for its rules, as on the write"); it does not move the verdict.

③ Boundary flags

Dev deviations (os-dev-report 6085563448), each answered:

  • origin/main (446c8b2a64) merged into the branch before the PR — ANSWERED, accepted: the net diff against that merge base is exactly the PR's 3 files; at my read the join is 1 commit / 52 files, disjoint (①, Join).
  • A first full-suite invocation with a bare -- was stopped within seconds, killing only the four recorded PIDs — ANSWERED: housekeeping within the discipline (guard-process-kill), nothing in the diff.
  • An unneeded downstream build inside one lock hold — ANSWERED: housekeeping; nothing measured from it, nothing owed.
  • Two derived gates red on the pre-merge diff and fixed in the PR (check:objectql-double-limit, check:query-options-erasure); check:dual-build-cjs-loads exit 3 once, then 0 — ANSWERED: both fixes are in the net diff (commit 754bd6119c counts header reads through an engine middleware instead of a Map-backed counter in the driver double; 57c3ce9f57 types the header read's options as EngineQueryOptions); the local battery is not an input here, and CI's Lint & Repo Gates carries all three families on the head, in progress at my read.
  • Commit trailers and the PR footer in AGENTS.md's model-free forms, not the harness's — ANSWERED: the repo rule wins by its own terms; correct.
  • CI in progress at report time — ANSWERED: recorded above at my own read; the landing waits for the four in-progress runs, Lint & Repo Gates among the required seven.

open_questions: none declared — nothing to answer.

out_of_scope_findings, each answered or escalated:

  • The write reads the master-detail header elevated, so each write discloses one bit of a header the caller cannot read (admits under draft, refuses required under sent); the reference check admitted the unreadable header id too — reported as class none as a defect (the resolveMasterDetailParent docblock declares the elevation deliberate: the caller's right to write the detail is settled upstream by RLS / controlled_by_parent, ADR-0055; the tenant wall is kept). ESCALATED to the owning seat as a security question to card or to close with a ruling: whether a detail may be writable by a caller who cannot read its header decides whether this is design or a defect, and Prime Directive chore: version packages #10 wants the evidence named either way (the dev's throwaway measurement is described in the report; the test was deleted). Not a condition on this PR: the preview takes the stricter rule, and the write is unchanged.
  • The orphaned docblock from objectql: an update-mode validate() 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 rides this PR — ANSWERED: done in the diff (①-9).

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 size/l label was set by another actor (reported by the dev, not corrected). The seat lands it through the queue once the branch is readied and the four in-progress runs have concluded success; an in-progress required context is not a pass.

Implemented-by: claude/issue-22474-preview-binds-parent
Reviewed-by: session_01Bw3y2DWhT9RPnrmDsNqEVG

VERDICT: PASS — every accept-set and surface change the diff implies is named right above: the preview binds parent and previousParent from the view and under the gate the write's rule evaluation uses, through the two-door served-as-stored read the #22445 PASS record set as the standard, pinned on the cannot-read, hidden and masked shapes with their preconditions asserted and controlled against the real writes and the real import; the level is right and the one overbroad changeset clause moves nothing; every dev flag is answered and the one standing escalation (the write's elevated header read) is the seat's. The landing waits only for the in-progress check-runs to conclude.


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 9, 2026 17:28
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 9, 2026 17:28
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 9, 2026
Merged via the queue into main with commit da989bb Oct 9, 2026
36 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-22474-preview-binds-parent branch October 9, 2026 17:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

2 participants