fix(lead): tombstone a confirmed duplicate claim when its survivor is erased (#1164) - #1175
Merged
Merged
Conversation
… erased (#1164) Deleting a contact — or the account above it, which cascades — was refused whenever a lead had been disqualified as a confirmed duplicate of that record: DELETE /api/v1/data/crm_contact/<id> → 400 "Disqualifying a lead as Duplicate requires naming the surviving record and setting Duplicate Status to Confirmed" #1072 cleared the same wall for leads carrying the machine's `suspected` guess. `duplicate_disqualification_requires_survivor` (#598) is a second, independent rule on the same path, and it is the drain path of the review queue: every lead a reviewer ever closed as a confirmed duplicate held its survivor hostage. `crm_lead.duplicate_of_type` gains a third value, `erased`, and `lead_duplicate_check` splits the retirement on what the record already says: a `suspected` guess is still retired whole, while a human's `confirmed` verdict is tombstoned — the type becomes `erased`, the status stands, and the lead goes on saying "confirmed duplicate of a record that has since been erased". Nothing is relaxed, which is the point. A validation is evaluated against `{...previous, ...data}` and cannot see a transition, so "the pointer was erased" and "this claim never named anyone" were the same state; a distinct value makes them different facts. The `requiredWhen` pairing fires only on `crm_lead` / `crm_contact` so it never sees the tombstone, and #598's rule wants a non-blank type plus `confirmed`, both of which a tombstoned lead has. The value is written, never authored: the lead forms offer only the authorable object types, and it is stamped from exactly one line, pinned by a source scan. It stays labelled in all four locales so a tombstoned lead reads as "Erased Record" rather than a raw enum. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019TYoxKa8yFLiDDh7tkBqtu
|
The latest updates on your projects. Learn more about Vercel for GitHub. |
os-zhuang
marked this pull request as ready for review
August 16, 2026 06:25
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.
Description
Fixes #1164
Lets a GDPR erasure complete against a lead a reviewer already closed as a
confirmed duplicate, without deleting the verdict they recorded.
Deleting a Contact — or the Account above it, which cascades — was refused
whenever any lead had been disqualified as a duplicate of that record:
This is the drain path of the Suspected Duplicates review queue, so every lead a
reviewer ever closed as a confirmed duplicate held its survivor hostage. #1072
cleared the same wall for leads still carrying the machine's
suspectedguess;duplicate_disqualification_requires_survivor(#598) is the second, independentrule sitting on the same path.
Implements the maintainer decision of 2026-08-16 on the card — constraint (b)
"no new vocabulary" yields — in the third-
duplicate_of_type-value shape, notthe
duplicate_statusshape.Type of Change
Related Issues
Fixes #1164
Related to #1072, #1166, #598, #688, #1149
Changes Made
src/objects/_picklists.ts— theduplicate_of_typevocabulary, declared astwo sets:
DUPLICATE_OF_TYPE_AUTHORABLE_OPTIONS(the two object names ahuman may pick) and
DUPLICATE_OF_TYPE_OPTIONS(those plus the tombstoneerased). The object spreads the full set, the form spreads the authorableone, so the tombstone is unpickable by construction rather than by a
hand-kept exclusion list.
src/objects/lead.hook.ts—lead_duplicate_checkjob 1c splits theretirement on what the record already says:
suspected(or no opinion) isstill retired whole; a human's
confirmedverdict is tombstoned — thetype becomes
erased, the status stands.src/views/lead.view.ts— the shared duplicate-link block narrows thepicker's options. All eight form surfaces inherit it.
src/translations/{en,zh-CN,ja-JP,es-ES}.ts— the value is labelled in allfour locales. Unauthorable is not the same as invisible: a tombstoned lead
reads "Erased Record", not a raw enum.
content/docs/sales/leads.mdx— a new "When the surviving record is laterdeleted" section.
Why nothing was relaxed, and why that is the whole design
A validation is evaluated against
{...previous, ...data}and cannot see atransition, so on the record "the pointer was erased" and "this claim never
named anyone" were the same state. Every predicate taught to tolerate the
first also admits the second, and #598 dies with it. That is why the three
shapes measured in the closed PR #1172 all failed.
A distinct value makes the two states different facts, and then every
existing rule stands unedited:
requiredWhenpairingcrm_lead/crm_contact, so it never fires on a tombstoneduplicate_disqualification_requires_survivor(#598)confirmed; a tombstoned lead has bothview-predicate-dialect.test.ts:414)visibleOnstill byte-identical torequiredWhenlead-duplicate-management.test.ts:142+)lead.hook.tscontains nodisqualification_reason(:295)duplicate_status, which it already reads and writesTwo pins the card's table did not list did need updating, and both are
vocabulary inventories rather than rules:
links through a type discriminator…and%s labels the picklist VALUES…each enumeratedduplicate_of_type's values as exactlycrm_lead/crm_contact. Those arethe assertions that record the vocabulary, so they are precisely what the
decision to yield "no new vocabulary" moves. Neither is a predicate, a rule or a
doctrine pin. Both were re-derived from
_picklists.tsrather than re-spelledby hand, and the first was strengthened from a flat three-value list into the
structural property that actually matters: every authorable value is backed by a
lookup targeting it, and the tombstone is backed by none.
Keeping the tombstone written, never authored
the walk asserting it found the picker at all so the pin cannot go vacuously
green.
erasedis spelled in exactly two lines ofsrc/— itsdeclaration and the single line that stamps it. Pinned by a source scan over
the whole tree with comments stripped, so it fails on a flow, action or hook
nobody thought to test, and on a second stamp inside
lead.hook.tsitself.the write to be silent about
duplicate_status. The engine'sset_nullcleanup arrives as exactly
{ id, link: null, updated_at, updated_by }; acaller that names the status is hand-managing the claim, and that claim is
retired whole. No payload can manufacture a tombstone by supplying its own
confirmed.Residual, as the decision accepted: the value is declared, so a direct API write
can still set it. That is not closable declaratively — a rule refusing it
would refuse the cleanup's own write, since neither can see a transition. The
hole is visible and labelled rather than invisible.
Testing
end-to-end against a real
ObjectKernelinsteadLocal gate union, all at final head
07a39310:Both delete paths are covered: the contact directly, and the account cascading
through it via the
crm_contact.crm_accountmaster-detail.Not run:
pnpm test:e2e(Playwright,e2e.yml) — untouched by this change;codeql.yml,link-check.yml,docs-app.yml,changeset-check.yml,labeler.yml,label-sync.yml. Per #1149,pnpm verifyis not the full CI gateset, so the list above is what was actually run rather than a claim about CI.
Reverse verification — five ablations
Each was committed first, then mutated, run, restored with
git checkout {branch} -- {path}and confirmed byte-identical(
git diff HEAD --quietclean every time).Disqualifying a lead as Duplicate requires naming the surviving record and setting Duplicate Status to Confirmed, the card's reported symptom reproduced verbatima lead form offers duplicate_of_type without narrowing its options — the erased tombstone is pickable there'duplicate_status' in inputsilence guard removedexpected 'erased' to be null; a hand-managed write manufactures a tombstonelead.hook.tsexpected 2 occurrences … but got 3, both offending lines printederasedremoved from the object's declared optionsDuplicate Of must be one of: crm_lead…The last one is the informative one and was run to answer a question rather than
to confirm a guess: it measures that the object-level option list is
enforced, not decorative. The engine rejects an undeclared select value, so
the tombstone genuinely has to be declared — which is also why the API-side
authorability residual above cannot be closed by omitting it.
Additional Notes
Closed PR #1172 is the evidence for the road not taken; nothing from that branch
is reused here — this is a fresh branch off
origin/main.Machine guesses are still retired whole, exactly as #1072 / #1166 decided. Only
a human's confirmed verdict is tombstoned.
Generated by Claude Code