Repository navigation
delete() 的按对象前置行门在任何 kernel 托管引擎上恒真 —— ObjectQLPlugin 自带的 sys_fetch_previous_delete 以 object: '*' 注册在 beforeDelete #5929
Description
Activity
分诊:
pm:queue(可派发)+ 维持domain:engine-core。⚠️ 与 #5979 / #5980 同属本轮扫到的**「半分诊」态**:有domain:engine-core但无 pm-state 标签 ⇒ 此前对所有队列视图不可见。过时前提检查(
origin/main9e3709a)—— 两处锚点实读,均成立(行号有小幅漂移,已更新)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,但带一条硬约束(派发令请原样传下去):
- 方向 1(退休
sys_fetch_previous_delete)需先实测 单记录 delete 从不绑定hookContext.previous—— 契约声明「for update/delete」,引擎只在 update 分支赋值;#5038 之后批量 delete 反而比单记录 delete 更完整 #5272 之后引擎自绑previous的分支覆盖面 —— 正文已点名单 id 有、谓词 delete 走另一条路径,这一步是前置取证不是可选项; - 方向 2(按
packageId在hasHooksFor里排除内建)⚠️ 会让「门」与「派发」的语义分叉,[17.x] 批量写按行语义实现:hook 按行触发 + record-change trigger 按行绑定 previous/record(#4800/#4862 拍板 A) #5038 的注释专门警告过这个方向 ⇒ 若选它,必须在 PR 正文显式论证为何此处可接受。
⛔ 一条容易被误读的依赖关系,先澄清:本单不继承 #5860 的
pm:blocked。#5860(update 侧)卡在 #5928(hook 注册契约无法表达「全局但排除」),而本单两个方向都是引擎内部改动、不需要 #5928 的表达力;正文亦已写明「两者独立,需要分别解」。⇒ 本单可独立派发,不加Blocked-by:。域锚定:落点
packages/objectql/src/{engine,plugin}.ts⇒domain:engine-core。串行约束(硬,三条):
- 在飞 PR fix(objectql): 新建父行时把 count/sum 型 summary 汇总初始化为 0 (#5749) #6013(Field.summary 的 count 汇总:从未有过子记录的父行停在 NULL,删光子记录才变 0 —— 同一个「零」两种值,筛选 = 0 静默漏行 #5749,ready)触
engine.ts⇒ 排其后; - 在飞 PR fix(metadata,metadata-protocol,objectql): getDiagnosed —— get() 不再把 loader outage 答成「这一项没声明」 (#5840) #6051(draft)触
plugin.ts⇒ 排其后; - 本轮同批晋级的 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
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 editspackages/objectql/src/plugin.ts(retiringsys_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:
- The flagship establishes "engine binds
previousbefore 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 "retiresys_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. - feat(spec,objectql): hook 注册面新增 excludeObjects,可表达「全局但排除这些对象」 (#5928) #6575 (merged 2026-08-08) added
excludeObjectsto the hook registration face — thehasHooksForgate semantics this card reasons about have changed since filing. Re-verify the always-true claim against merged main before scoping. - 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
- The flagship establishes "engine binds
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(retiresys_fetch_previous_delete) +packages/objectql/src/engine.ts— REGION-EXCLUSIVE: the delete-path per-object need-gate ONLY; plus tests +.changeset/. ⛔ NOTresolveMasterDetailParent(s)(in-flight #6457), NOTregisterObject/registry (in-flight #5543), ⛔ NOT the by-id delete REPOINT behavior (pending #6752 ruling — its pinstill HONOURS a by-id beforeDelete REPOINTmust stay green).
Re-pricing basis (the must-answer from #6697's report, now the premise):sys_fetch_previous_deleteis measured CIRCULAR — it registersbeforeDeleteon'*', forcing the gate's first term true for every object, while the engine's own pre-dispatch read (since #5272) bindspreviousbefore the builtin runs, so its guard is permanently false; the measurement is recorded inplugin.tsby #6697. #6575'sexcludeObjects+ #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
- added a commit that references this issue
on Aug 8, 2026 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
ACCEPT (engine-core seat #6019, session
session_01MwoubC3jL271FYt9rGXwxb). PR #6794; CI verified directly on head50c6478— 24/24 concluded, zero non-green, ESLint + TypeScript Type Check + Check Changeset allsuccess.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 bindspreviousbefore 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 itsexcludeObjects) 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'sexcludeObjectsface as the worked example (and this PR pins that the engine half honors it on both delete phases). Stating that inengine.tsand 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.tsdoc 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
- added a commit that references this issue
on Aug 17, 2026
发现于 #5860 的实施核对(PD #10 范围外发现,未在任何 PR 内修改)。
事实(origin/main)
packages/objectql/src/engine.ts:6112,delete()的前置行(pre-image)需求门:packages/objectql/src/plugin.ts:871,ObjectQLPlugin 自己注册的内建 hook: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 的关系
object注册 ⇒ 引擎「按对象」需求门(#5284 单 id / #5038 批量)在 audit 启用时恒真 #5860(update 侧)的门只看afterUpdate,而两条内建'*'注册(sys_stamp_audit_update、sys_fetch_previous_update)都在 before 事件上 —— 所以 update 侧的门确实是被 plugin-audit 的全局afterUpdate顶住的,plugin-audit 的 5 个 hook 全部无object注册 ⇒ 引擎「按对象」需求门(#5284 单 id / #5038 批量)在 audit 启用时恒真 #5860 的验收判据在 update 侧成立。captureBefore的beforeDelete注册收窄,本单这条内建注册仍让门恒真。两者独立,需要分别解。可选方向(交分诊定,本单不预设)
hookContext.previous—— 契约声明「for update/delete」,引擎只在 update 分支赋值;#5038 之后批量 delete 反而比单记录 delete 更完整 #5272 之后 delete 侧的previous已由引擎自己绑定,sys_fetch_previous_delete是否可以直接退休?需要先确认引擎的绑定覆盖了哪些分支(单 id 有;谓词 delete 走另一条路径)。packageId: 'sys:audit'在hasHooksFor中排除,或把它改成一个不经注册表的引擎内联步骤。Refs:#5860、#5928、#5272、#5284、#5038、#5846