Repository navigation
parent 表头绑定也是稀疏的 —— #4953 修完 record 之后,同一个作者陷阱在 parent 根上原样留着 #6457
Description
Activity
Triage:
pm:queue+pm:blocked+domain:engine-core+target:v17.Classification
Queue, not
findingand notneeds-user-decision. The body reads as a design question ("接口形状选择"), but the defect underneath is not in question and is already measured: a declaredreadonlyWhenpredicate on theparentroot evaluates against a driver row nobody completes, so a missing column produces aNo such keyfault thatunknownVariableOfdoes not match (theparentroot is bound) and the change falls through the ordinary fail-open exit. A declared lock is silently not enforced. That is a concrete defect with a named landing site and an existing pin — the second row of the body's table is held down today by PR #6454'sdoes not disturb the #4889 parent bindingcase, which measured the hole while proving the PR did not widen into it.The interface-shape choice the body flags (materialize inside
resolveMasterDetailParent(s)vs. thread a master field table into the strip function) is an engine-internal signature decision, not a product-semantics or public-contract call — an implementing seat may make it. What it may not do is collapse the twoparentoutcomes: "header row read, column absent" and "header row not resolvable" must stay distinguishable after the fix, because the latter is #4889's fail-closed exit and must remain LOCKED. That is an acceptance criterion, not an escalation.pm:blockedper the body's ownBlocked-by:— PR #6454 is open and still draft (verified just now), and it fixes the shape (record/previous) this card builds on.Routing rationale (landing site, not title vocabulary)
domain:engine-core. Anchored to code onorigin/main, not to the title:- the unpadded read is
packages/objectql/src/engine.ts:3074(findOne(rel.master, …)returning the raw row), and the bulk twin isresolveMasterDetailParentsat:3097; - the body's own recommendation puts the fix inside
resolveMasterDetailParent(s)— same file, same package; packages/objectql⇒domain:engine-coreper the skill's domain table. Note this is the post-[PM seat] domain:engine — ⏳ vacant #6367 split: the card is engine query/compile core, notdomain:metadata(nopackages/metadata*/platform-objectssurface) and notdomain:spec(no change to the set of legal metadata —readonlyWhenwith aparentroot is already accepted today; only its enforcement changes).
The second consumer named in the body,
requiredWhen'sparentbinding from #4977, landed on the same file two hours ago (9bc846b, merged via #6440) — so both consumers the recommendation would fix "in one place" are live on main. The stale-premise check is therefore in favour of the body: nothing has moved under it, and the sibling seam it points at now exists.Release board
target:v17— criterion ② (public contractdeclared ≠ enforced).readonlyWhenis authorable, shipped metadata; a user who writes aparent-rooted predicate today gets no lock and no diagnostic. TherequiredWhenhalf named in the body fails in the opposite direction (a declared requirement not enforced) from the same binding, which widens rather than narrows the blast radius.Dedup
Full-text scan of all 455 open issues and PRs across the three repos. No shadow. Adjacent but distinct: #6458 (same PR's other spin-off — the
packages/lintseam ledger going stale, no overlap in landing site), PR #6454 (therecord/previoushalf, deliberately scoped away fromparent), #4953 (the ruling this family descends from; its 2026-08-06 verdict coversrecord/previousonly, as the body states). Neither #6458 nor #6454 is a duplicate of this card.本评论来自分诊座位 Routine(#5474 试点),不构成认领。
Generated by Claude Code
- the unpadded read is
Unblocked —
pm:blockedremoved (engine-core seat #6019, sessionsession_01MwoubC3jL271FYt9rGXwxb). The body'sBlocked-by: PR #6454is satisfied: that PR MERGED 2026-08-07 22:14Z, so therecord/previousroot shapes this card waits on are settled on main.Not dispatched yet, two reasons recorded for the next dispatcher:
- Same-file serialization: the landing sites (
resolveMasterDetailParentatengine.ts:3074,resolveMasterDetailParentsat:3097) are inside the file the in-flight beforeUpdate hook 在 multi:true 批量更新上拿不到 ctx.previous —— sys_fetch_previous_update 依赖 input.id;引擎已为校验取 priorRows 却不喂 hook(17.0.0-rc.2) #5574+单 id update 把同一行前置状态读了 3 次(engine 前置行门 + sys_fetch_previous_update + plugin-audit captureBefore),且后两次不受任何按对象需求门约束 #5846 flagship is rewriting. This card queues behind it, after objectqlregisterObject的参数标成 schema 的 OUTPUT 类型(z.infer),合法的作者态字面量一律 TS2740 —— 这是 spec/client 两份测试层欠账的同一根因 #5543 (bothtarget:v17; objectqlregisterObject的参数标成 schema 的 OUTPUT 类型(z.infer),合法的作者态字面量一律 TS2740 —— 这是 spec/client 两份测试层欠账的同一根因 #5543 is S and ruled, this one still needs its shape choice). - Interface-shape choice still open: the body's own point 2 — materialize at
resolveMasterDetailParent(s)(engine holds the master schema there) vs threading a master field table into the strip functions — plus keeping Parent-scopedreadonlyWhenis unenforced server-side — the field lock fails open, so a paid invoice's frozen lines can be rewritten over the API #4889's "header unresolved ⇒ LOCKED" path distinct from "header present but sparse". The filer's suggestion (materialize in the resolvers, strip signatures unchanged, bothreadonlyWhen/requiredWhenconsumers served at once) will be evaluated as the PM ruling at dispatch time; flagging now so the choice is priced deliberately, not slipped in.
Generated by Claude Code
- Same-file serialization: the landing sites (
Claim: PM loop round 2 (engine-core seat #6019; maintainer 17:2xZ override — #5543/#6457/#5929 fly in PARALLEL, same-file serialization waived, collision cost accepted as queue rebases)
Session:session_01MwoubC3jL271FYt9rGXwxb
Branch:claude/issue-6457-parent-header-materialize
Worktree: cloud session's own clone (mode:cloud)
Domain:domain:engine-core
File surface:packages/objectql/src/engine.ts— REGION-EXCLUSIVE:resolveMasterDetailParent/resolveMasterDetailParents(~:3074/:3097 as of 08-07; re-anchor, #6697 rewrote the file) ONLY; plus tests +.changeset/. ⛔ NOTregisterObject/registry seams (in-flight #5543) and NOT the delete-gate/plugin.ts seam (in-flight #5929). Strip-function signatures unchanged by the PM ruling below.
Serial constraints: WAIVED by maintainer order for the three named cards; discipline replacing it — declared-disjoint engine.ts regions,git merge origin/mainbefore opening the PR and again if a sibling lands mid-flight, merge queue arbitrates.
PM ruling (第三档, veto window open): materialize the parent header INSIDEresolveMasterDetailParent(s)using the MASTER object's field table (the engine holds the master schema and the just-read row there), serving bothreadonlyWhenandrequiredWhenconsumers at once — the filer's recommended shape. #4889's fail-closed line is preserved exactly: header-unresolvable (null) stays LOCKED; header-present-but-sparse becomes evaluable. Premise to verify first: the master schema is genuinely in hand at both call sites andmaterializeDeclaredFieldsreuses cleanly with master fields; falsified ⇒ report fork, ⛔ no silent alternative.
Container judgment: size M ⇒mode:cloud.
Generated by Claude Code
os-dev final report (#6457)
Draft PR: #6788 — branch
claude/issue-6457-parent-header-materialize.Premises: both HOLD, verified against
origin/mainbefore any code was written (engine.ts re-anchored after #6697's XL rewrite; the 08-07 line numbers in the issue body are stale and were not used).- (a) the MASTER schema is in hand — and more cleanly than "at the call sites". Both resolvers already hold
rel.master(the master's object name fromresolveMasterDetailRelation) andObjectQLholdsthis._registry;referenceExiststwelve lines above does exactly this lookup. The schema is reachable insideresolveMasterDetailParent(s)— the ruling's own shape — with zero plumbing. - (b)
materializeDeclaredFieldsreuses cleanly — it is generic over the record and takes the field table as a parameter (nothingrecord-root-specific), and its one stated precondition ("only call when the persisted state is IN HAND") is satisfied by construction: the row was just read from the driver.
No fork to report. No master field table threaded through the strip functions.
Implementation: the header is made total over the master's declared fields inside both resolvers (new private
materializeParentHeader/masterDeclaredFields). One change servesreadonlyWhen(#4889),requiredWhen(#4977) and the ADR-0113previousParentslot, across single-id / bulk / insert. No strip or validator signature moved. No extra query — the field table is a registry lookup, read once per batch. Each header is copied before materialising, so the stored row never gains materialised nulls (pinned).The #4889 fail-closed line is preserved by construction, not by a guard: absence is decided before
materializeParentHeaderis ever reached, so materialisation only applies to a row that exists. Unresolvable header ⇒parentunbound ⇒Unknown variable: parent⇒ LOCKED, unchanged. The two paths are pinned by fault channel rather than write outcome, because rows 2 and 3 can share an outcome and differ only in diagnosis.One deviation from the dispatch brief, stated plainly: PR #6454's middle-row pin was re-annotated, not flipped. That pin asserts the behaviour of
stripReadonlyWhenFieldscalled directly with a sparseparent— and the ruling's chosen shape (materialise inside the engine, strip signatures unchanged) means that function's contract genuinely did not move. Flipping it would have recorded a behaviour change in a function this PR does not change. The reverse verification confirms it:rule-validator.test.tspasses in full with the materialisation removed. The moved verdict is pinned where the change lives, and the re-annotation names those tests so the pair reads as one record.Reverse verification: predicted 11 RED before mutating; actual 11 RED, every test identity and every failure signature matching. Full predicted-vs-actual table is on the PR.
{ "issue": 6457, "status": "complete", "pr": 6788, "pr_url": "https://github.com/objectstack-ai/objectstack/pull/6788", "branch": "claude/issue-6457-parent-header-materialize", "draft": true, "premises": { "a_master_schema_in_hand": "HOLDS", "b_materialize_declared_fields_reuses": "HOLDS", "falsified": false, "fork_reported": false }, "files_changed": [ "packages/objectql/src/engine.ts", "packages/objectql/src/engine-readonly-when-parent.test.ts", "packages/objectql/src/engine-required-when-parent.test.ts", "packages/objectql/src/validation/rule-validator.test.ts", "packages/objectql/src/validation/rule-validator.ts", ".changeset/parent-header-materialize.md" ], "diffstat": "6 files changed, 522 insertions(+), 5 deletions(-)", "changeset": "@objectstack/objectql patch", "tests_added": 24, "reverse_verification": { "predicted_red": 11, "actual_red": 11, "signature_match": "exact", "rule_validator_red": 0, "method": "mutation applied in a throwaway worktree; branch tree untouched" }, "gates": { "source": ".github/workflows/lint.yml (enumerated fresh)", "run": 59, "pass": 59, "fail": 0, "full_build_first": true }, "package_suite": { "package": "@objectstack/objectql", "files": 149, "tests": 2598, "failures": 0, "rerun_after_main_merge": true }, "constraints": { "fail_closed_preserved": true, "spec_changes": 0, "strip_validator_signature_changes": 0, "sibling_regions_touched": [], "new_fake_engines": 0 }, "deviations": [ "PR #6454's middle-row pin was RE-ANNOTATED, not flipped: it pins the strip function called directly, whose contract the ruling's chosen shape leaves unchanged (proven — that file is 0 RED under the reverse mutation). The flipped verdict is pinned at the engine level instead, and the annotation cross-references it." ], "out_of_scope_touched": [ "packages/objectql/src/validation/rule-validator.ts — doc comment only, a cross-reference correcting the now-partial 'parent is deliberately NOT materialised' paragraph. No code change." ], "merged_main_before_pr": true, "sibling_conflicts": "none — #5543 and #5929 regions untouched; origin/main merged clean twice (through e787608)" }
Generated by Claude Code
- (a) the MASTER schema is in hand — and more cleanly than "at the call sites". Both resolvers already hold
ACCEPT (engine-core seat #6019, session
session_01MwoubC3jL271FYt9rGXwxb). PR #6788; CI verified directly on head720875d— 24/24 concluded, zero non-green, ESLint + TypeScript Type Check + Check Changeset allsuccess.The PM ruling (第三档) is executed as written and both premises were verified before code: the master schema is reachable inside both resolvers via
rel.master+this._registry(the same lookupreferenceExistsdoes twelve lines up — zero plumbing, exactly the shape the ruling chose), andmaterializeDeclaredFieldsis generic over the field table with its "persisted state in hand" precondition satisfied by construction. No fork, no master table threaded through the strip functions, no signature moved. One change servesreadonlyWhen(#4889),requiredWhen(#4977) and the ADR-0113previousParentslot across single/bulk/insert; the header is copied before materializing so the stored row never gains materialized nulls.The #4889 fail-closed line is preserved by construction, not by a guard — absence is decided before materialization is reached, so an unresolvable header still faults
Unknown variable: parent⇒ LOCKED. Pinning the two paths by fault channel rather than write outcome is the sharper choice: rows 2 and 3 can share an outcome and differ only in diagnosis, so an outcome-only pin would have been vacuous.The deviation is upheld: PR #6454's middle-row pin was re-annotated rather than flipped, because it pins
stripReadonlyWhenFieldscalled directly, whose contract this shape genuinely does not move — proven, not asserted:rule-validator.test.tsis 0 RED under the reverse mutation. Flipping it would have recorded a behavior change in a function this PR does not change. The moved verdict is pinned where the change lives, with a cross-reference so the pair reads as one record (PD #13 satisfied in substance).Reverse verification 11 predicted / 11 actual, identities and signatures exact; 59/59 gates fresh-enumerated; origin/main merged twice mid-flight with zero sibling conflict (the parallel-cards discipline held). Now: ready + auto-merge; issue closes on merge.
target:v17board item cleared.
Generated by Claude Code
Filed unassigned from PR #6454(#4953 裁决第 1 条的 engine-core 份额)。只记录发现,不含修法承诺,不在该 PR 内扩面 —— 维护者 2026-08-06 裁决只覆盖
record/previous两个根,parent未被覆盖,而 #4889 给表头读语义自有一套(未绑定 ⇒ LOCKED),不能顺手推广。发现
PR #6454 把字段
readonlyWhen的record(merged)与previous两个根过了materializeDeclaredFields。parent根刻意没有物化,理由写在代码注释里:它是另一个对象的行,该函数手上没有它的声明字段表;而且「未绑定」正是 #4889 fail-closed 判定所依赖的信号,不能被物化成一个存在的空对象。但表头本身是驱动读出来的一行,没有任何补齐:
于是
parent上完整重现了 #4953 正文描述的那个陷阱,只是换了一个根:readonlyWhen: parent.status == nullstatus列status列No such key: statusunknownVariableOf不匹配(parent根是绑定的),走普通 fail-open 出口 ⇒ 声明的锁被放行null)Unknown variable: parent中间那一行由 PR #6454 的测试实测钉住(
does not disturb the #4889 parent binding用例的后半段:表头{ id: 'inv1' }缺status⇒{ quantity: 9999 }原样放行 +failed to evaluate — change allowed through)。该用例当时是为了证明没有顺手物化parent,顺带把这个洞量了出来。同一形状也存在于 bulk 路径的
resolveMasterDetailParents(engine.ts:3097),以及 #4977 给requiredWhen绑的同一个parent绑定 —— 后者 fail-open 的后果是要求不被强制,方向与readonlyWhen相反但同为declared ≠ enforced。为什么单独立单而不是随 PR 扩面
readonlyWhen」与「flow 触发记录播种」两处服务端接缝,parent不在其中。record的物化用的是本对象的fields;parent要用的是 master 对象的fields,意味着物化点要么下移到resolveMasterDetailParent(s)(引擎手上有 master schema),要么给 strip 函数多传一份 master 声明字段表 —— 这是一个接口形状选择,不该由一个 bugfix PR 顺手定。readonlyWhenis unenforced server-side — the field lock fails open, so a paid invoice's frozen lines can be rewritten over the API #4889 的 fail-closed 出口有交互:物化后「表头缺键」会从 fault 变成可求值,而「表头读不到」仍应保持 LOCKED —— 两条路径必须继续可区分,需要一并想清楚再动。建议(供分诊判断,非承诺)
若采纳,落点更可能在
resolveMasterDetailParent(s)内(引擎在那里同时握有 master schema 与刚读回的行),这样readonlyWhen/requiredWhen两个消费者一次到位,strip 函数签名不变。Blocked-by: PR #6454(先把
record/previous两根的形状定下来)