Skip to content

delete() 的按对象前置行门在任何 kernel 托管引擎上恒真 —— ObjectQLPlugin 自带的 sys_fetch_previous_delete 以 object: '*' 注册在 beforeDelete #5929

Description

@baozhoutao

发现于 #5860 的实施核对(PD #10 范围外发现,未在任何 PR 内修改)。

事实(origin/main)

packages/objectql/src/engine.ts:6112,delete() 的前置行(pre-image)需求门:

const wantsPreImage =
  this.hasHooksFor('beforeDelete', object) ||
  this.hasHooksFor('afterDelete', object) ||
  this.getSummaryDescriptors(object).length > 0;

packages/objectql/src/plugin.ts:871,ObjectQLPlugin 自己注册的内建 hook:

{ name: 'sys_fetch_previous_delete', object: '*', events: ['beforeDelete'], priority: 5, ... }

hasHooksFor 把 '*' 正确地当作命中每个对象,于是只要 ObjectQLPlugin 在(即任何 kernel 托管的引擎),wantsPreImage 的第一项恒真、后两项永不被求值 —— 这个门在真实部署里从来没有判过假。

实测(计数驱动,只注册这一条内建形状,完全不装 plugin-audit):对一个既无 delete hook、也无 roll-up 汇总的对象做单 id delete(),driver.findOne 增量 = 1。

为什么这不是 #5272 的重复

#5272 把 delete 侧的前置读提前到派发 beforeDelete 之前并绑定 previous,让 before/after 两个阶段共用一次读 —— 那次改动是对的,而且正因如此,内建 sys_fetch_previous_delete 的 if (!hookCtx.previous) 现在恒为假,它自己不再多读一次。

剩下的是另一个问题:那条 object: '*' 的注册仍然留在注册表里,而需求门是按注册面算的,于是门本身失去了判假的能力。换句话说,内建 hook 的存在理由(「引擎不填 previous」)在 delete 侧已经不成立,但它的注册还在,并且它现在唯一可观察的效果,就是让按对象门恒真。

与 #5860 / #5928 的关系

可选方向(交分诊定,本单不预设)

  1. 既然 单记录 delete 从不绑定 hookContext.previous —— 契约声明「for update/delete」,引擎只在 update 分支赋值;#5038 之后批量 delete 反而比单记录 delete 更完整 #5272 之后 delete 侧的 previous 已由引擎自己绑定,sys_fetch_previous_delete 是否可以直接退休?需要先确认引擎的绑定覆盖了哪些分支(单 id 有;谓词 delete 走另一条路径)。
  2. 或者让内建 hook 不参与需求门的计算 —— 例如按 packageId: 'sys:audit' 在 hasHooksFor 中排除,或把它改成一个不经注册表的引擎内联步骤。⚠️ 前者会让「门」和「派发」的语义分叉,[17.x] 批量写按行语义实现:hook 按行触发 + record-change trigger 按行绑定 previous/record(#4800/#4862 拍板 A) #5038 的注释专门警告过这个方向,若走这条要显式论证。

Refs:#5860、#5928、#5272、#5284、#5038、#5846

Activity

  1. claude commented on Aug 6, 2026

    @claude
    Contributor

    分诊:pm:queue(可派发)+ 维持 domain:engine-core。

    ⚠️ 与 #5979 / #5980 同属本轮扫到的**「半分诊」态**:有 domain:engine-core 但无 pm-state 标签 ⇒ 此前对所有队列视图不可见。

    过时前提检查(origin/main 9e3709a)—— 两处锚点实读,均成立(行号有小幅漂移,已更新)

    • wantsPreImage 三元判据在 packages/objectql/src/engine.ts:**6125**(正文记 :6112,已前移;:6145 / :6160 两处消费点同在);
    • sys_fetch_previous_delete 的 object: '*' / events: ['beforeDelete'] 注册在 packages/objectql/src/plugin.ts:**888**(正文记 :871)。

    ⇒ 「只要 ObjectQLPlugin 在,wantsPreImage 第一项恒真、后两项永不求值」这个事实面成立。正文的计数实测(driver.findOne 增量 = 1)是可复现判据。

    为什么入队而不是 finding:落点已钉死到两个文件的具体行,且有计数驱动的复现;后果是每一次 kernel 托管引擎上的 delete() 都多一次 findOne(对无 hook、无 roll-up 的对象也照读),即今天在跑的真实开销,不是休眠声明。两个候选方向都落在 packages/objectql 内部,不改任何对外契约 ⇒ 属车道内技术选型,⛔ 不进决策箱。

    方向选择交 dev,但带一条硬约束(派发令请原样传下去):

    ⛔ 一条容易被误读的依赖关系,先澄清:本单不继承 #5860 的 pm:blocked。#5860(update 侧)卡在 #5928(hook 注册契约无法表达「全局但排除」),而本单两个方向都是引擎内部改动、不需要 #5928 的表达力;正文亦已写明「两者独立,需要分别解」。⇒ 本单可独立派发,不加 Blocked-by:。

    域锚定:落点 packages/objectql/src/{engine,plugin}.ts ⇒ domain:engine-core。

    串行约束(硬,三条):

    1. 在飞 PR fix(objectql): 新建父行时把 count/sum 型 summary 汇总初始化为 0 (#5749) #6013(Field.summary 的 count 汇总:从未有过子记录的父行停在 NULL,删光子记录才变 0 —— 同一个「零」两种值,筛选 = 0 静默漏行 #5749,ready)触 engine.ts ⇒ 排其后;
    2. 在飞 PR fix(metadata,metadata-protocol,objectql): getDiagnosed —— get() 不再把 loader outage 答成「这一项没声明」 (#5840) #6051(draft)触 plugin.ts ⇒ 排其后;
    3. 本轮同批晋级的 ObjectQL seedAutonumber 把读故障答成 return 0 —— 对已有 N 行的表重新从 1 发号,自增号与既有行相撞且零日志(#4825 同族,活体) #5979 同落 engine.ts ⇒ 两单 ⛔ 不得同轮派发。

    查重:三仓 open issue / PR 各搜一遍 —— #5860(update 侧,pm:blocked)、#5928(注册契约表达力,pm:queue/domain:spec)、#5272 / #5284 / #5038 均为关联而非重复,各自入口不同;objectui / cloud 无影子单。

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


    Generated by Claude Code

  2. os-zhuang commented on Aug 8, 2026

    @os-zhuang
    Contributor

    Deferred this round — same-seam serialization + mandatory re-pricing (engine-core seat #6019, round 1, session session_01MwoubC3jL271FYt9rGXwxb).

    Held behind the in-flight #5574+#5846 unified card (branch claude/issue-5574-bulk-before-per-row), which edits packages/objectql/src/plugin.ts (retiring sys_fetch_previous_update) and the hook-dispatch seam this card's per-object gate lives in — same-file rides in different rounds, no exception.

    Known traps recorded for whoever dispatches this next; do NOT reuse the filing-time cost estimate:

    1. The flagship establishes "engine binds previous before dispatch" on the update side and retires the update-side builtin. The delete side has bound-before-dispatch since 单记录 delete 从不绑定 hookContext.previous —— 契约声明「for update/delete」,引擎只在 update 分支赋值;#5038 之后批量 delete 反而比单记录 delete 更完整 #5272, so this card may shrink to "retire sys_fetch_previous_delete + make the per-object need-gate computation honest" — or parts may become unnecessary. The flagship dev's report carries a must-answer on exactly this question; read it before dispatching.
    2. feat(spec,objectql): hook 注册面新增 excludeObjects,可表达「全局但排除这些对象」 (#5928) #6575 (merged 2026-08-08) added excludeObjects to the hook registration face — the hasHooksFor gate semantics this card reasons about have changed since filing. Re-verify the always-true claim against merged main before scoping.
    3. Adjacent, not duplicate: plugin-audit 的 5 个 hook 全部无 object 注册 ⇒ 引擎「按对象」需求门(#5284 单 id / #5038 批量)在 audit 启用时恒真 #5860 (identity lane) owns plugin-audit's registration-face expression; this card owns the objectql builtin's side.

    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-5929-retire-delete-prev-builtin
    Worktree: cloud session's own clone (mode:cloud)
    Domain: domain:engine-core
    File surface: packages/objectql/src/plugin.ts (retire sys_fetch_previous_delete) + packages/objectql/src/engine.ts — REGION-EXCLUSIVE: the delete-path per-object need-gate ONLY; plus tests + .changeset/. ⛔ NOT resolveMasterDetailParent(s) (in-flight #6457), NOT registerObject/registry (in-flight #5543), ⛔ NOT the by-id delete REPOINT behavior (pending #6752 ruling — its pin still HONOURS a by-id beforeDelete REPOINT must stay green).
    Re-pricing basis (the must-answer from #6697's report, now the premise): sys_fetch_previous_delete is measured CIRCULAR — it registers beforeDelete on '*', forcing the gate's first term true for every object, while the engine's own pre-dispatch read (since #5272) binds previous before the builtin runs, so its guard is permanently false; the measurement is recorded in plugin.ts by #6697. #6575's excludeObjects + #5860's delivered registration face mean the gate can now be honest.
    Serial constraints: WAIVED by maintainer order for the three named cards; discipline replacing it — declared-disjoint regions, merge origin/main before PR and on sibling landings, merge queue arbitrates.
    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 (#5929)

    {
      "issue": 5929,
      "status": "delivered",
      "branch": "claude/issue-5929-retire-delete-prev-builtin",
      "pr": 6794,
      "pr_url": "https://github.com/objectstack-ai/objectstack/pull/6794",
      "pr_state": "draft",
      "premise_verification": {
        "as_filed": "CONFIRMED — `sys_fetch_previous_delete` registered on `beforeDelete` with `object: '*'`, so `hasHooksFor('beforeDelete', object)` was true for every object on every kernel-managed engine and the per-object skip never happened outside bare-engine unit tests.",
        "as_upgraded_by_6697": "CONFIRMED and independently re-measured — the builtin was circular: the engine binds `previous` before `beforeDelete` dispatches (by-id since #5272, per matched row since #6697), so its `!ctx.previous` guard was permanently false and it issued no read; its only remaining effect was holding open the gate that made it redundant.",
        "measurement_cited": "packages/objectql/src/plugin.ts, pre-change lines 920-939, inside #5846's `sys_fetch_previous_update` RETIRED block — quoted verbatim in the PR body.",
        "other_consumers": "NONE LIVE. Grep on the quoted exact name returned 3 hits: the declaration, its own `plugin.ts` comment, and one doc comment in plugin-auth/src/last-admin-guard.ts that names it as a co-producer of `ctx.previous` while stating in the same sentence that the guard never consumes it. That comment was corrected, not treated as a consumer. No report-instead-of-force condition."
      },
      "gate_terms_enumerated_from_current_main": {
        "location": "packages/objectql/src/engine.ts, delete() by-id branch (`wantsPreImage`)",
        "terms": [
          "hasHooksFor('beforeDelete', object)",
          "hasHooksFor('afterDelete', object)",
          "getSummaryDescriptors(object).length > 0"
        ],
        "no_validation_term": "`needsPriorRecord(schema)` deliberately absent — delete() evaluates no validation rules, so the term would buy a read with no reader. No summary/validation term was added post-#6697.",
        "predicate_branch_twin_gate": "terms 1 and 2 only, untouched",
        "terms_touched": "NONE. The gate expression is unchanged; the fix is entirely the removal of the `'*'` registration that made term 1 constant-true. engine.ts gains only the A2 enumeration recorded beside `wantsPreImage`."
      },
      "A1": {
        "claim": "retiring the builtin changes NO observable binding anywhere",
        "answer": "TRUE — no hole in the circularity premise",
        "method": "reverse verification: builtin removed, NO gate change, full @objectstack/objectql suite run",
        "predicted": "fully green; no test names the builtin as a live registration (grep established this beforehand)",
        "actual": "Test Files 149 passed (149) · Tests 2575 passed (2575) · 0 failures"
      },
      "A2": {
        "question": "does any remaining '*'-registered delete-phase builtin silently re-open the gate for every object?",
        "answer": "No `'*'`-SPELLED one remains, but FOUR packages achieve the same thing by omitting `object` — declared rather than hidden",
        "still_global_and_holding_the_gate_open": [
          "plugin-auth/src/identity-write-guard.ts — beforeDelete, no `object`; filters `isManaged(ctx.object)` inside the handler",
          "plugin-sharing/src/record-share-cascade.ts — before+afterDelete, no `object`; filters `targets(objectName)` inside",
          "service-storage/src/file-reference-lifecycle.ts — before+afterDelete, no `object`; filters `activeFileFields(object)` inside",
          "plugin-audit/src/audit-writers.ts — before+afterDelete, global MINUS `excludeObjects: AUDIT_EXCLUDED_OBJECTS` (#5860); excluded objects genuinely skip the read"
        ],
        "object_scoped_and_therefore_harmless": [
          "plugin-auth/src/last-admin-guard.ts (4 registrations)",
          "plugin-sharing/src/rule-hooks.ts, primary-bu-projection.ts, sharing-plugin.ts",
          "service-storage/src/attachment-lifecycle.ts, attachment-access-hooks.ts",
          "plugin-audit/src/comment-access-hooks.ts"
        ],
        "verdict": "The four global ones are real consumers with real handlers that decide applicability at dispatch time; the gate answering yes for them is the gate working, not a second #5929. What made the builtin a defect is that it consumed nothing. Narrowing any of them is that package's own card — plugin-audit's `excludeObjects` face is the worked example, and this PR pins that the engine half of it works on both delete phases. Stated in engine.ts and in the changeset so nobody expects a skip that will not appear on a full kernel.",
        "sibling_of_sys_stamp_audit_update_on_the_delete_side": "none — ObjectQLPlugin's surviving builtins are `sys_stamp_audit_insert` (beforeInsert) and `sys_stamp_audit_update` (beforeUpdate) only, pinned as such in the new test file."
      },
      "acceptance_pins": {
        "file": "packages/objectql/src/engine-delete-prior-read-scope.test.ts (new, 16 cases, delete-side counterpart of engine-update-prior-read-scope.test.ts)",
        "kernel_without_delete_hooks_no_prior_read": "PINNED — real ObjectKernel + ObjectQLPlugin, per-object driver findOne AND find deltas both 0 on a single-id delete()",
        "kernel_with_real_beforeDelete_previous_still_bound": "PINNED — `previous` bound, from exactly ONE read (the engine's #5272/#6697 read; the count is what proves nothing else supplied it)",
        "builtin_absent_from_the_booted_engine": "PINNED — hasHooksFor beforeDelete/afterDelete false, while beforeInsert/beforeUpdate stay true (absence of one hook, not of the builtin set)",
        "excludeObjects_honored": "PINNED both phases — excluded object skips the read and does not dispatch; non-excluded pays it and does. Registered via engine.registerHook because bindHooksToEngine refuses an absent `object` (#4001), so global-minus-exclusions is only expressible on the engine API — which is the API plugin-audit uses.",
        "also_pinned": "term 1 alone / term 2 alone / term 3 alone; '*' still demanding the read everywhere; needsPriorRecord explicitly NOT a term; predicate-path twin gate both directions; the retired builtin's shape replayed with its guard measured short-circuiting to zero reads; the residual empty-read shape leaving `previous` UNBOUND not fabricated (#4649/#4775)."
      },
      "repoint_untouched": {
        "pin": "bulk-write-per-row-hooks.test.ts — 'still HONOURS a by-id `beforeDelete` REPOINT — deliberately not retired here'",
        "state": "GREEN",
        "reasoning": "the repoint re-read is guarded by the same `wantsPreImage` value computed once before the before phase; that computation is unchanged, only what one of its inputs answers on a kernel. #6752 left entirely alone."
      },
      "reverse_verification_of_the_new_pins": {
        "method": "builtin temporarily restored from origin/main, new test file re-run",
        "predicted": "exactly 2 red — `registers NO beforeDelete hook of its own` (hasHooksFor true) and `a single-id delete on a hook-free object performs NO prior-row read` (delta 1, not 0). The `previous` still bound from ONE read case stays GREEN because the builtin's guard short-circuits. All bare-engine cases green.",
        "actual": "Tests 2 failed | 14 passed (16) — 'expected true to be false' and 'expected 1 to be +0'. Nothing else moved. Signature matched exactly.",
        "bonus_evidence": "with the builtin PRESENT, the hooked-object case still measured exactly ONE read — the cleanest independent confirmation that the builtin issued none, i.e. the circularity claim.",
        "harness_bugs_found_and_fixed_not_papered_over": [
          "the counting driver's deleteMany/updateMany/count routed through their own find(), charging the engine for reads it never issued (every predicate-path number was one too high)",
          "bindHooksToEngine refuses an absent `object` (#4001), so the first excludeObjects case passed for the wrong reason — the hook had never registered at all"
        ]
      },
      "gates": {
        "source": "enumerated fresh from .github/workflows/lint.yml, every check:* one by one; NODE_OPTIONS=--max-old-space-size=4096, --maxWorkers=2; full build run before check:type-check-debt",
        "result": "ALL PASS",
        "initially_red_then_fixed": [
          "pnpm lint / check:slot-lookup — `kernel.getService('objectql') as any` erased the slot contract for every engine.* call in the kernel section; typed as getService<ObjectQL>('objectql')",
          "check:query-options-erasure — three `count(obj, {} as any)` grew the test surface 263 -> 266; the empty bag is already on contract, assertion dropped"
        ],
        "passed": ["lint", "check:slot-lookup", "check:query-options-erasure", "check:verify-stand-in", "check:nul-bytes", "check:doc-authoring", "check:docs-audit-scope", "check:role-word", "check:quick-reference-counts", "check:adr-anchors", "check:org-identifier", "check:authz-resolver", "check:service-providers", "check:route-envelope", "check:error-code-casing", "check:wildcard-fallthrough", "check:meta-type-normalized", "check:init-service-contract", "check:durability-log-level", "check:startup-registry-verdict", "check:objectui-changeset", "check:release-notes", "check:release-body", "check:node-version", "check:workflow-status-functions", "check:shard-attestation", "check:published-files", "check:engine-double-contract", "check:kernel-hook-pairs", "check:resume-authority-declared", "check:driver-memory-census", "check:merge-driver", "check:spec-parsed-alias", "check:type-check-coverage", "check:driver-conformance", "check:stall-guard", "check:skill-frame-sync", "check:skill-compatibility", "check:i18n", "check:i18n-coverage", "check:app-nav-i18n", "spec exec tsc --noEmit", "spec check:generated --reconcile-only", "spec check:skill-docs", "spec check:spec-changes", "spec check:upgrade-guide", "spec check:authorable-surface", "spec check:docs", "spec check:skill-refs", "spec check:react-blocks", "turbo build (packages + nested)", "check:type-check-debt", "turbo typecheck (packages + nested + apps)", "examples typecheck", "downstream-contract typecheck", "spec check:api-surface", "spec check:exported-any", "spec check:dual-source-exports", "spec check:skill-examples", "lint check:doc-formula-expressions"]
      },
      "tests": {
        "objectql_final": "151 files / 2609 tests passed",
        "downstream_suites_all_green": {
          "plugin-audit": 122,
          "plugin-auth": 974,
          "plugin-sharing": 376,
          "service-storage": 284,
          "core": 524,
          "runtime": 1733,
          "trigger-record-change": 56,
          "metadata-core": 124,
          "service-automation": 868,
          "service-messaging": 199,
          "metadata-protocol": 700
        }
      },
      "files_changed": [
        "packages/objectql/src/plugin.ts — the retirement (+ the binder debug line that still advertised `previousData`, a producer that no longer exists)",
        "packages/objectql/src/engine.ts — delete-gate region comment only; NO term changed",
        "packages/plugins/plugin-auth/src/last-admin-guard.ts — one stale doc comment naming the retired builtin",
        "packages/objectql/src/engine-delete-prior-read-scope.test.ts — new",
        ".changeset/retire-delete-fetch-previous-builtin.md — @objectstack/objectql patch, both directions"
      ],
      "scope_discipline": {
        "sibling_regions_untouched": "no edits in the registerObject/registry region (#5543) or resolveMasterDetailParent(s) (#6457)",
        "no_spec_changes": true,
        "no_plugin_audit_edits": true,
        "no_releases_page_edits": true,
        "new_fake_engines": "none — the tests drive the REAL ObjectQL engine behind a fake DRIVER, which check-engine-double-contract vetoes out of scan scope (DRIVER_ONLY_MEMBERS + primary-key-parameter test), so no assertEngineDeleteDispatch obligation arises; check:engine-double-contract passes",
        "worktree": "dedicated worktree /home/user/objectstack-5929; no git stash used",
        "merge_discipline": "origin/main merged immediately before opening the PR and again before the final push; no conflicts, and the second merge brought no objectql changes so the suite results stand"
      },
      "blockers": [],
      "needs_user_decision": []
    }

    Generated by Claude Code

  6. os-zhuang commented on Aug 8, 2026

    @os-zhuang
    Contributor

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

    Both premises independently re-measured rather than inherited: the filed one ('*' registration makes the per-object gate constant-true) and #6697's upgrade (the builtin was circular — the engine binds previous before dispatch, so its guard was permanently false and it issued no read; its only remaining effect was holding open the gate that made it redundant). A1 proved by the cleanest possible experiment — builtin removed, gate untouched, full suite green (149 files / 2575 tests) — so the retirement is provably binding-neutral. The gate expression itself is unchanged: the fix is purely removing the registration that made term 1 constant, which is the minimal correct shape.

    A2 is the answer that earns the acceptance. Rather than declaring victory, it enumerates the four packages that remain effectively global by omitting object (plugin-auth identity-write-guard, plugin-sharing record-share-cascade, service-storage file-reference-lifecycle, plugin-audit minus its excludeObjects) and draws the right line: those are real consumers with real handlers deciding applicability at dispatch time — the gate answering yes for them is the gate working. What made the builtin a defect is that it consumed nothing. Narrowing each is that package's own card, with plugin-audit's excludeObjects face as the worked example (and this PR pins that the engine half honors it on both delete phases). Stating that in engine.ts and the changeset means nobody expects a skip that will not appear on a full kernel — exactly the honesty the ADR-0049 family requires.

    The #6752 boundary held: the by-id repoint pin stays green and untouched. The last-admin-guard.ts doc comment naming the builtin as a co-producer was corrected rather than treated as a consumer — right call. 16 new acceptance pins as the delete-side counterpart of the update-side file. Now: ready + auto-merge; issue closes on merge.


    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