Skip to content

parent 表头绑定也是稀疏的 —— #4953 修完 record 之后,同一个作者陷阱在 parent 根上原样留着 #6457

Description

@baozhoutao

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 判定所依赖的信号,不能被物化成一个存在的空对象。

但表头本身是驱动读出来的一行,没有任何补齐:

packages/objectql/src/engine.ts:3074
  const row = await this.findOne(rel.master, { where: { id: parentId }, context: { isSystem: true } } as any);
  return (row as Record<string, unknown>) ?? null;

于是 parent 上完整重现了 #4953 正文描述的那个陷阱,只是换了一个根:

情形 readonlyWhen: parent.status == null 结果
表头行带 status 列 正常求值 按谓词真假锁 / 不锁
驱动没回读 status 列 fault No such key: status unknownVariableOf 不匹配(parent 根是绑定的),走普通 fail-open 出口 ⇒ 声明的锁被放行
表头解析不到(null) fault Unknown variable: parent LOCKED(#4889 的 fail-closed 出口)

中间那一行由 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 扩面

  1. 裁决没覆盖:2026-08-06 的裁决逐条列的是「字段 readonlyWhen」与「flow 触发记录播种」两处服务端接缝,parent 不在其中。
  2. 修法不是同一套:record 的物化用的是本对象的 fields;parent 要用的是 master 对象的 fields,意味着物化点要么下移到 resolveMasterDetailParent(s)(引擎手上有 master schema),要么给 strip 函数多传一份 master 声明字段表 —— 这是一个接口形状选择,不该由一个 bugfix PR 顺手定。
  3. 与 Parent-scoped readonlyWhen is 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 两根的形状定下来)

Activity

  1. claude commented on Aug 7, 2026

    @claude
    Contributor

    Triage: pm:queue + pm:blocked + domain:engine-core + target:v17.

    Classification

    Queue, not finding and not needs-user-decision. The body reads as a design question ("接口形状选择"), but the defect underneath is not in question and is already measured: a declared readonlyWhen predicate on the parent root evaluates against a driver row nobody completes, so a missing column produces a No such key fault that unknownVariableOf does not match (the parent root 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's does not disturb the #4889 parent binding case, 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 two parent outcomes: "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:blocked per the body's own Blocked-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 on origin/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 is resolveMasterDetailParents at :3097;
    • the body's own recommendation puts the fix inside resolveMasterDetailParent(s) — same file, same package;
    • packages/objectql ⇒ domain:engine-core per the skill's domain table. Note this is the post-[PM seat] domain:engine — ⏳ vacant #6367 split: the card is engine query/compile core, not domain:metadata (no packages/metadata* / platform-objects surface) and not domain:spec (no change to the set of legal metadata — readonlyWhen with a parent root is already accepted today; only its enforcement changes).

    The second consumer named in the body, requiredWhen's parent binding 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 contract declared ≠ enforced). readonlyWhen is authorable, shipped metadata; a user who writes a parent-rooted predicate today gets no lock and no diagnostic. The requiredWhen half 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/lint seam ledger going stale, no overlap in landing site), PR #6454 (the record/previous half, deliberately scoped away from parent), #4953 (the ruling this family descends from; its 2026-08-06 verdict covers record / previous only, as the body states). Neither #6458 nor #6454 is a duplicate of this card.

    本评论来自分诊座位 Routine(#5474 试点),不构成认领。


    Generated by Claude Code

  2. os-zhuang commented on Aug 8, 2026

    @os-zhuang
    Contributor

    Unblocked — pm:blocked removed (engine-core seat #6019, session session_01MwoubC3jL271FYt9rGXwxb). The body's Blocked-by: PR #6454 is satisfied: that PR MERGED 2026-08-07 22:14Z, so the record/previous root shapes this card waits on are settled on main.

    Not dispatched yet, two reasons recorded for the next dispatcher:

    1. Same-file serialization: the landing sites (resolveMasterDetailParent at engine.ts:3074, resolveMasterDetailParents at :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 objectql registerObject 的参数标成 schema 的 OUTPUT 类型(z.infer),合法的作者态字面量一律 TS2740 —— 这是 spec/client 两份测试层欠账的同一根因 #5543 (both target:v17; objectql registerObject 的参数标成 schema 的 OUTPUT 类型(z.infer),合法的作者态字面量一律 TS2740 —— 这是 spec/client 两份测试层欠账的同一根因 #5543 is S and ruled, this one still needs its shape choice).
    2. 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-scoped readonlyWhen is 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, both readonlyWhen/requiredWhen consumers 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

  3. self-assigned this
    on Aug 8, 2026
  4. os-zhuang commented on Aug 8, 2026

    @os-zhuang
    Contributor

    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/. ⛔ NOT registerObject/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/main before opening the PR and again if a sibling lands mid-flight, merge queue arbitrates.
    PM ruling (第三档, veto window open): materialize the parent header INSIDE resolveMasterDetailParent(s) using the MASTER object's field table (the engine holds the master schema and the just-read row there), serving both readonlyWhen and requiredWhen consumers 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 and materializeDeclaredFields reuses cleanly with master fields; falsified ⇒ report fork, ⛔ no silent alternative.
    Container judgment: size M ⇒ mode:cloud.


    Generated by Claude Code

  5. os-zhuang commented on Aug 8, 2026

    @os-zhuang
    Contributor

    os-dev final report (#6457)

    Draft PR: #6788 — branch claude/issue-6457-parent-header-materialize.

    Premises: both HOLD, verified against origin/main before 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 from resolveMasterDetailRelation) and ObjectQL holds this._registry; referenceExists twelve lines above does exactly this lookup. The schema is reachable inside resolveMasterDetailParent(s) — the ruling's own shape — with zero plumbing.
    • (b) materializeDeclaredFields reuses cleanly — it is generic over the record and takes the field table as a parameter (nothing record-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 serves readonlyWhen (#4889), requiredWhen (#4977) and the ADR-0113 previousParent slot, 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 materializeParentHeader is ever reached, so materialisation only applies to a row that exists. Unresolvable header ⇒ parent unbound ⇒ 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 stripReadonlyWhenFields called directly with a sparse parent — 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.ts passes 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

  6. os-zhuang commented on Aug 8, 2026

    @os-zhuang
    Contributor

    ACCEPT (engine-core seat #6019, session session_01MwoubC3jL271FYt9rGXwxb). PR #6788; CI verified directly on head 720875d — 24/24 concluded, zero non-green, ESLint + TypeScript Type Check + Check Changeset all success.

    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 lookup referenceExists does twelve lines up — zero plumbing, exactly the shape the ruling chose), and materializeDeclaredFields is 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 serves readonlyWhen (#4889), requiredWhen (#4977) and the ADR-0113 previousParent slot 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 stripReadonlyWhenFields called directly, whose contract this shape genuinely does not move — proven, not asserted: rule-validator.test.ts is 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:v17 board item cleared.


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions