fix(hooks): 被拦下的删除要指名真正拦它的那条记录 (#693) - #719
Merged
Merged
Conversation
Two cascade-guard refusals named an object — and one of them an operation —
the caller had never touched:
DELETE /api/v1/data/crm_account/<id>
→ 400 "Cannot delete contact: still referenced by 1 open opportunity(ies), …"
DELETE /api/v1/data/crm_opportunity/<id>
→ 400 "Cannot edit a converted lead (attempted: converted_opportunity).
Make changes on the converted records instead."
Both reproduced end-to-end on a real kernel (ObjectQLPlugin + AppPlugin over
this app's own metadata, 17.0.0-rc.2) before any edit; the messages are
verbatim what the guards produce.
Each guard has TWO invocation contexts and no way to tell them apart:
`contact_integrity`'s beforeDelete runs on a direct contact delete AND as a
cascade child of an account delete (crm_contact.crm_account is master-detail /
cascade); the beforeUpdate locks run on a hand edit AND on the engine's
referential clear, since cascadeDeleteRelations implements `set_null` by
UPDATING the row that holds the lookup. Measured: the cascaded hook context
carries no cascade marker at all — the same keys, and a `session` that is the
caller's own (pinned by the first test in cascade-guard-messages.test.ts, so a
future platform version that DOES mark it fails loudly rather than silently).
So the fix is not to detect the cascade. Every refusal is now phrased from the
BLOCKING RELATIONSHIP, which is true in both contexts: it names the record that
refuses, the link or the references that block, and what to do.
Swept the sibling hooks for the same construction and found two more, reachable
with the same two REST calls: deleting a contact a CLOSED opportunity names as
primary_contact answered "Opportunity is closed (closed_won); … Attempted:
primary_contact.", and deleting an opportunity an ACCEPTED quote references
answered "Quote is accepted; only internal_notes may be edited. Attempted:
crm_opportunity." Both now name the frozen record and the link.
What is refused is UNCHANGED — same deletes blocked, same reasons, same records
surviving. Wording only; the semantics assertions in the new file pass with or
without this change.
Reverse-verified: reverting the four hooks turns 5 of the 9 end-to-end tests and
16 unit assertions red on message content, while the no-cascade-marker probe,
both "refuses and leaves the chain in place" cases and the unblocked-delete case
stay green — the split the change predicts.
Refs #693
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VHrPAGEgFDoHjphqYG4BMa
|
The latest updates on your projects. Learn more about Vercel for GitHub. |
This was referenced Aug 5, 2026
yinlianghui
marked this pull request as ready for review
August 5, 2026 15:01
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 #693
先复现,再动手
在真实内核上(
ObjectKernel+ObjectQLPlugin+AppPlugin,跑本仓自己的objectstack.config.ts,平台 17.0.0-rc.2)把 issue 的两条现象原样跑了出来,一个字都没改之前:
前提成立:两条文案就是
contact.hook.ts/lead.hook.ts里的守卫抛出来的,经由平台的级联层原样上浮。拒绝本身是对的,本 PR 一个拒绝语义都没有动。
为什么不能靠“检测级联”来修
每个守卫都有两种被调用的方式,而它分不出自己身处哪一种:
contact_integrity的beforeDelete:直接删联系人会走到,删客户时也会走到(
crm_contact.crm_account是deleteBehavior: 'cascade'的主从关系)。beforeUpdate锁(转换线索锁、已关闭商机冻结、已接受报价冻结):手工编辑会走到,引擎的引用清理也会走到 ——
cascadeDeleteRelations实现set_null的方式就是去 UPDATE 持有该 lookup 的那一行,所以“删掉被引用的记录”会以
{ 字段: null }的形状抵达守卫。实测(17.0.0-rc.2):级联情形下 hook 上下文里没有任何级联标记,键集合与直接
写入完全一致,
session就是调用者本人的。这条测量本身被cascade-guard-messages.test.ts的第一个用例钉住了 —— 将来平台若真加了标记,这个用例会红,那时可以把文案升级成
“所以这个客户不能删”,比现在更好。
因此改的是说法:每条拒绝都从阻塞关系出发陈述,这在两种调用方式下都成立。
改完之后(真实级联产生的原文)
顺带扫出的同类缺陷(同一构造,issue 未报)
按“同一类构造”扫了
src/objects/*.hook.ts,发现另外两处,用同样两个 REST 调用就能撞上,因此一并改文案:
DELETE crm_contact/{id}(被某个已关闭商机当primary_contact)Opportunity is closed (closed_won); … Attempted: primary_contact.primary_contact这条链接DELETE crm_opportunity/{id}(被某张已接受报价引用)Quote is accepted; only internal_notes may be edited. Attempted: crm_opportunity.crm_opportunity这条链接account.hook.ts/product.hook.ts的删除守卫查过了:没有任何对象级联进crm_account/crm_product,它们永远只在被直接寻址时触发,文案本来就对,未改。判定规则
只有当被拒绝的字段全部是该对象上的引用字段、且都是从有值改成
null时,才走“链接被清空”那一支;混着业务字段的写入仍旧按“编辑”报,并照旧列出
attempted:字段。手工把同一个字段置空得到的是同一句话 —— 因为那句话在两种情况下都为真。
验证
pnpm test→57 files / 1368 passed | 1 skippedpnpm typecheck→ 干净pnpm validate→✓ Validation passed;pnpm build→ 产物含新文案(L2 body 已下沉)pnpm lint/pnpm hygiene→ 仅既有告警反向验证(方向先定后跑):预测“把四个 hook 还原 → 文案类断言变红、语义类断言保持绿”。
实跑:端到端 9 个用例红 5 绿 4(绿的是无级联标记探针、两个“拒绝且链路原样保留”、以及
“无人引用时客户仍可删”),单测另有 16 条断言变红。语义断言与本改动无关这一点,是它们绿着
证明的。
平台侧的一个问题(未改,留给维护者)
converted_*链接的清理来自引擎维护引用完整性,并不是“用户在编辑线索”。转换线索锁是否本就不该拦这一类写入(拦了会让被引用的商机在线索删除前无法删除),属于拒绝语义,
本 PR 按约定不动,已在报告里作为待决问题提出。
Generated by Claude Code