fix(objectql): retire sys_fetch_previous_delete so the delete-path per-object gate is honest (#5929) - #6794
Merged
Conversation
…per-object gate is honest (#5929) `delete()`'s prior-row read is gated per object on `hasHooksFor('beforeDelete', object) || hasHooksFor('afterDelete', object) || getSummaryDescriptors(object).length > 0`. On any kernel-hosted engine the first term was constant true, because `ObjectQLPlugin` registered its own `sys_fetch_previous_delete` builtin with `object: '*'` — so the per-object skip the gate exists to perform never happened outside the bare engines unit tests boot. The builtin could not use what it held open. Since #5272 (by-id) and #6697 (predicate path, per matched row) the engine reads the pre-image and binds `previous` before `beforeDelete` dispatches, 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. Retired under ADR-0049 enforce-or-remove; the measurement #5846 recorded in `plugin.ts` was re-verified on this branch rather than taken on trust. The gate's three terms are unchanged — no term was added or removed. What changed is that term 1 now reflects real hooks. `engine.ts` gains the enumeration of the delete-phase hooks that still register globally (plugin-auth, plugin-sharing, service-storage; plugin-audit narrows at the engine face with `excludeObjects`), so nobody reads a skip into a trace that will not show one. New `engine-delete-prior-read-scope.test.ts` pins the three terms per object, the `excludeObjects` subtraction on both phases, the predicate path's twin gate, and — on a real `ObjectKernel` + `ObjectQLPlugin`, the only configuration where the defect was observable — the zero-read skip and the still-bound `previous`. It replays the retired builtin's own shape and measures its guard short-circuiting, so "the guard can no longer be true" stays a measurement. The by-id `beforeDelete` REPOINT behaviour is deliberately untouched (#6752). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UNT8SWDEsDQp2TrmSBizKq
…ire-delete-prev-builtin
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
Contributor
📓 Docs Drift CheckThis PR changes 2 package(s): 19 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
|
…hets (#5929) Two lint.yml gates caught the new test file on the first full run: * `check:slot-lookup` / `no-restricted-syntax` — `kernel.getService('objectql') as any` erased the slot's contract for every `engine.*` call in the kernel section, which is where the measurement lives. Typed as `getService<ObjectQL>('objectql')`. * `check:query-options-erasure` — three `count(obj, {} as any)` calls grew the test-surface count 263 → 266. The empty options bag is already on contract; the assertion was never needed. No assertion changed; the file still passes 16/16. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UNT8SWDEsDQp2TrmSBizKq
…ire-delete-prev-builtin
os-zhuang
marked this pull request as ready for review
August 8, 2026 19:21
This was referenced Aug 8, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #5929
The premise, verified before anything was removed
The card was filed 08-06 and both files it names were rewritten on 08-08 by #6697, so every claim was re-anchored on current
mainrather than taken from the issue text.Premise as filed:
delete()'s per-object prior-row gate is constant true on any kernel-managed engine, becauseObjectQLPlugin'ssys_fetch_previous_deleteregisters onbeforeDeletewithobject: '*'. CONFIRMED.Premise as upgraded by #6697's measurement: the builtin is circular — the engine binds
previousbeforebeforeDeletedispatches, so the builtin's!ctx.previousguard is permanently false and its only remaining effect is holding open the gate that makes it redundant. CONFIRMED, and re-measured rather than trusted (see A1 below).The measurement #6697 left behind, cited as asked —
packages/objectql/src/plugin.ts(pre-change lines 920–939), inside thesys_fetch_previous_updateretirement block:Other consumers of the retired name — checked first, as instructed. Grep for the quoted exact name across the repo (declarations, registrations, docs, JSON) returned exactly three hits: the declaration and the
plugin.tscomment above it, plus one doc comment inpackages/plugins/plugin-auth/src/last-admin-guard.tswhich names the builtin as a co-producer ofctx.previousand states in the same sentence that the guard "still never consumes it". No live consumer. That comment is corrected here rather than left describing a hook that no longer exists.The work
1. Retired
sys_fetch_previous_delete(packages/objectql/src/plugin.ts) under ADR-0049 enforce-or-remove, replaced by a⛔ RETIREDblock in the shape #5846 used for its update-side twin: the argument, the residual shape, and — new — an explicit statement of what the retirement does not buy, so the next reader does not over-read the result.Also corrected in the same file: the binder's debug line still advertised
previousDataas something these builtins produce. With both fetch-previous builtins retired, it named a producer that no longer exists.2. The delete-path per-object gate. Enumerated fresh from current
main(engine.ts, by-id branch). It has three terms:hasHooksFor('beforeDelete', object)hasHooksFor('afterDelete', object)getSummaryDescriptors(object).length > 0There is no summary/validation term beyond #3, and
needsPriorRecord(schema)is deliberately absent (its own comment says why:delete()evaluates no validation rules, so the term would buy a read with no reader). The predicate branch carries its own twin gate, terms 1 and 2 only, likewise untouched.I changed no term. The gate was already written honestly; what was dishonest was its first term's answer, and the
'*'registration was the entire cause. Retiring the builtin is the whole fix. Whatengine.tsgains is the A2 enumeration recorded besidewantsPreImage, so the honest gate is not mistaken for a usually-false one.3. Acceptance pins — new
packages/objectql/src/engine-delete-prior-read-scope.test.ts(16 cases), the delete-side counterpart ofengine-update-prior-read-scope.test.ts:delete()performs zero prior-row reads — asserted as driver call counts, per object, on a realObjectKernel+ObjectQLPlugin(the only configuration where the defect was ever observable);beforeDeleteon that same kernel:previousstill arrives bound, from exactly one read — the engine's 单记录 delete 从不绑定hookContext.previous—— 契约声明「for update/delete」,引擎只在 update 分支赋值;#5038 之后批量 delete 反而比单记录 delete 更完整 #5272/feat(objectql): dispatch before* hooks per matched row on a predicate bulk write (#5574, #5846) #6697 read, and the count is what proves nothing else supplied it;beforeDelete/afterDeletehook of its own, while the surviving stamp builtins stay bound (asserts the absence of one hook, not of the builtin set);excludeObjects(feat(spec,objectql): hook 注册面新增 excludeObjects,可表达「全局但排除这些对象」 (#5928) #6575 semantics / plugin-audit 的 5 个 hook 全部无object注册 ⇒ 引擎「按对象」需求门(#5284 单 id / #5038 批量)在 audit 启用时恒真 #5860's delivered registration face): an excluded object skips the read and does not dispatch; a non-excluded one pays it and does. Both phases. Registered throughengine.registerHook— the binder refuses an absentobjectrather than widening it (未知键静默剥离仍是全仓默认:把 #3405 的 strict 收紧从一个 schema 推广到整个可授权面(ADR-0078 完整性闸门) #4001), so global-minus-exclusions is only expressible on the engine API, which is the API plugin-audit uses;'*'still demanding the read everywhere, andneedsPriorRecordexplicitly not a term;previousunbound, not fabricated (Script validation rules are silently skipped when their predicate fails to evaluate — fail-open is the wrong direction for a validation #4649/hook 的condition求不出值时:全局 fail loud —— 抛错并中断该次操作(方案 B 已拍板;Blocked-by #4770) #4775).4. The by-id delete REPOINT is untouched (#6752 pending).
bulk-write-per-row-hooks.test.ts'sstill HONOURS a by-id beforeDelete REPOINT — deliberately not retired hereis green. Nothing in this diff changes when the repoint's re-read happens: the re-read is guarded by the samewantsPreImagevalue computed once before the before phase, and that value's computation is unchanged — only what one of its inputs answers on a kernel.A1 — "retiring the builtin changes no observable binding anywhere"
ANSWERED: true. Reverse-verified in the direction the card asked for.
Predicted: with the builtin removed and no gate change, the full
@objectstack/objectqlsuite stays green, with no failure at all — grep having already established that no test names the builtin as a live registration, only as prose.Actual:
Test Files 149 passed (149) · Tests 2575 passed (2575). Zero failures. The premise has no hole.A2 — "does another
'*'delete-phase builtin silently re-open the gate?"ANSWERED: no
'*'-spelled one remains — but four packages register delete-phase hooks withobjectabsent, which is the same thing by another spelling. Enumerated exhaustively; every delete-phase registration in non-test source was read and classified:objectqlplugin.tsbeforeDeleteobject: '*'plugin-authidentity-write-guard.tsbeforeDeleteobject⇒ globalisManaged(ctx.object)inside the handlerplugin-sharingrecord-share-cascade.tsbefore+afterDeleteobject⇒ globaltargets(objectName)inside the handlerservice-storagefile-reference-lifecycle.tsbefore+afterDeleteobject⇒ globalactiveFileFields(object)inside the handlerplugin-auditaudit-writers.tsbefore+afterDeleteexcludeObjects: AUDIT_EXCLUDED_OBJECTS(#5860)plugin-authlast-admin-guard.tsbeforeDelete×4plugin-sharingrule-hooks.ts,primary-bu-projection.ts,sharing-plugin.tsservice-storageattachment-lifecycle.ts,attachment-access-hooks.tsobject: 'sys_attachment'plugin-auditcomment-access-hooks.tsbeforeDeleteobject: 'sys_comment'The four global ones are real consumers with real handlers — they merely decide applicability at dispatch time rather than at registration time. The gate answering "yes" for them is the gate working, not a second instance of #5929: what made the builtin a defect was that it consumed nothing. Narrowing any of them to the objects it actually serves is that package's own card; plugin-audit's
excludeObjectsface is the worked example of how, and this PR pins that the engine half of it works on both delete phases. This is stated inengine.tsand in the changeset so nobody expects a skip on a full kernel that will not appear.Reverse verification of the new pins
A pin that cannot fail is not a pin. Predicted-then-actual, with the builtin temporarily restored from
origin/mainand the new file re-run:Predicted: exactly two cases red —
registers NO beforeDelete hook of its own(hasHooksFortrue) anda single-id delete on a hook-free object performs NO prior-row read(delta 1, not 0). Thepreviousstill bound from ONE read case stays green, because the builtin's guard short-circuits and it adds no read. Every bare-engine case stays green (a bare engine never carried the builtin).Actual:
Tests 2 failed | 14 passed (16)—registers NO beforeDelete hook of its own:expected true to be false;a single-id delete on a hook-free object performs NO prior-row read:expected 1 to be +0. Nothing else moved.That second run is also the cleanest independent confirmation of the circularity claim: with the builtin present, the hooked-object case still measured exactly one read — so the builtin issued none, exactly as #6697 recorded.
Two harness bugs were found and fixed by this exercise rather than papered over: the counting driver's
deleteMany/updateMany/countrouted through their ownfind(), charging the engine for reads it never issued; andbindHooksToEnginerefuses an absentobject(#4001), so theexcludeObjectsface had to be registered throughengine.registerHook— the first version's "excluded object skips the read" passed for the wrong reason, because the hook had never registered at all.Scope
packages/objectql/src/plugin.ts(the retirement) ·packages/objectql/src/engine.ts(delete-gate region comment only — no term changed) · one stale doc comment inplugin-auth/src/last-admin-guard.ts· the new test file ·.changeset/. No spec changes, no plugin-audit edits, nothing in theregisterObject/registry region (#5543) orresolveMasterDetailParent(s)(#6457).origin/mainmerged immediately before opening this PR.Generated by Claude Code