Skip to content

fix(hooks): 被拦下的删除要指名真正拦它的那条记录 (#693) - #719

Merged
yinlianghui merged 1 commit into
mainfrom
claude/issue-693-cascade-guard-messages
Aug 5, 2026
Merged

fix(hooks): 被拦下的删除要指名真正拦它的那条记录 (#693)#719
yinlianghui merged 1 commit into
mainfrom
claude/issue-693-cascade-guard-messages

Conversation

@yinlianghui

Copy link
Copy Markdown
Collaborator

Fixes #693

先复现,再动手

在真实内核上(ObjectKernel + ObjectQLPlugin + AppPlugin,跑本仓自己的
objectstack.config.ts,平台 17.0.0-rc.2)把 issue 的两条现象原样跑了出来,
一个字都没改之前:

DELETE crm_account/{id}
→ "Cannot delete contact: still referenced by 1 open opportunity(ies), 0 active quote(s), 0 active contract(s). Reassign first."

DELETE crm_opportunity/{id}
→ "Cannot edit a converted lead (attempted: converted_opportunity). Make changes on the converted records instead."

前提成立:两条文案就是 contact.hook.ts / lead.hook.ts 里的守卫抛出来的,
经由平台的级联层原样上浮。拒绝本身是对的,本 PR 一个拒绝语义都没有动。

为什么不能靠“检测级联”来修

每个守卫都有两种被调用的方式,而它分不出自己身处哪一种:

  • contact_integritybeforeDelete:直接删联系人会走到,删客户时也会走到
    crm_contact.crm_accountdeleteBehavior: 'cascade' 的主从关系)。
  • 三个 beforeUpdate 锁(转换线索锁、已关闭商机冻结、已接受报价冻结):手工编辑
    会走到,引擎的引用清理也会走到 —— cascadeDeleteRelations 实现 set_null
    方式就是去 UPDATE 持有该 lookup 的那一行,所以“删掉被引用的记录”会以
    { 字段: null } 的形状抵达守卫。

实测(17.0.0-rc.2):级联情形下 hook 上下文里没有任何级联标记,键集合与直接
写入完全一致,session 就是调用者本人的。这条测量本身被 cascade-guard-messages.test.ts
的第一个用例钉住了 —— 将来平台若真加了标记,这个用例会红,那时可以把文案升级成
“所以这个客户不能删”,比现在更好。

因此改的是说法:每条拒绝都从阻塞关系出发陈述,这在两种调用方式下都成立。

改完之后(真实级联产生的原文)

Contact Ada Lovelace (crm_contact-…) is still referenced by 1 open opportunity(ies),
0 active quote(s), 0 active contract(s), so it cannot be deleted — and neither can
its account, because deleting an account deletes its contacts. Close or reassign
those records first.

Converted lead Bo Chen (crm_lead-…) is locked, so its link(s) converted_opportunity
cannot be cleared — which also blocks deleting the record(s) they point at. Delete
the lead first, or have an admin clear the link with a system write.

顺带扫出的同类缺陷(同一构造,issue 未报)

按“同一类构造”扫了 src/objects/*.hook.ts,发现另外两处,用同样两个 REST 调用就能
撞上,因此一并改文案:

调用 旧文案 新文案指名
DELETE crm_contact/{id}(被某个已关闭商机当 primary_contact Opportunity is closed (closed_won); … Attempted: primary_contact. 商机名 + id + primary_contact 这条链接
DELETE crm_opportunity/{id}(被某张已接受报价引用) Quote is accepted; only internal_notes may be edited. Attempted: crm_opportunity. 报价名 + id + crm_opportunity 这条链接

account.hook.ts / product.hook.ts 的删除守卫查过了:没有任何对象级联进
crm_account / crm_product,它们永远只在被直接寻址时触发,文案本来就对,未改。

判定规则

只有当被拒绝的字段全部是该对象上的引用字段、且都是从有值改成 null 时,才走
“链接被清空”那一支;混着业务字段的写入仍旧按“编辑”报,并照旧列出 attempted: 字段。
手工把同一个字段置空得到的是同一句话 —— 因为那句话在两种情况下都为真。

验证

  • pnpm test57 files / 1368 passed | 1 skipped
  • pnpm typecheck → 干净
  • pnpm validate✓ Validation passedpnpm build → 产物含新文案(L2 body 已下沉)
  • pnpm lint / pnpm hygiene → 仅既有告警

反向验证(方向先定后跑):预测“把四个 hook 还原 → 文案类断言变红、语义类断言保持绿”。
实跑:端到端 9 个用例红 5 绿 4(绿的是无级联标记探针、两个“拒绝且链路原样保留”、以及
“无人引用时客户仍可删”),单测另有 16 条断言变红。语义断言与本改动无关这一点,是它们绿着
证明的。

平台侧的一个问题(未改,留给维护者)

converted_* 链接的清理来自引擎维护引用完整性,并不是“用户在编辑线索”。转换线索锁
是否本就不该拦这一类写入(拦了会让被引用的商机在线索删除前无法删除),属于拒绝语义,
本 PR 按约定不动,已在报告里作为待决问题提出。


Generated by Claude Code

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
@vercel

vercel Bot commented Aug 5, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
hotcrm Ignored Ignored Aug 5, 2026 2:56pm

Request Review

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

Labels

backend Server-side behaviour — hooks, flows, actions ci/cd CI plumbing and the verification pipeline metadata Declarative metadata — schema, security posture, UI surfaces

Projects

None yet

Development

Successfully merging this pull request may close these issues.

删除被级联守卫拦下时,报错文案指向了错误的对象("删除客户"报成"Cannot delete contact")

2 participants