Repository navigation
A lead flagged as a duplicate makes the contact (or lead) it duplicates undeletable: the same #696 / #711 construction on crm_lead's duplicate_of_* pairing #1072
Description
Found while implementing #720 (freeze guards yielding to the engine's reference cleanup). Independent of that fix and not masked by it — the two are only adjacent.
Platform: @objectstack/* 17.0.0-rc.6. Measured with the real kernel (ObjectKernel + ObjectQLPlugin + AppPlugin over this repo's objectstack.config.ts), the rig test/cascade-guard-messages.test.ts assembles.
Mechanism
src/objects/lead.hook.ts's lead_duplicate_check stamps the duplicate link automatically at intake — any NEW lead whose normalized email matches an existing contact gets:
duplicate_of_type = 'crm_contact'
duplicate_of_contact = the contact's id
duplicate_status = 'suspected'
src/objects/lead.object.ts declares that pairing as a field-level requiredWhen:
duplicate_of_contact: requiredWhen has(record.duplicate_of_type) && record.duplicate_of_type == "crm_contact"
duplicate_of_lead: requiredWhen has(record.duplicate_of_type) && record.duplicate_of_type == "crm_lead"
Both lookups declare no deleteBehavior, so they resolve to the spec default set_null. Deleting the survivor therefore nulls the lookup while the discriminator duplicate_of_type stays behind, the row the engine just edited immediately violates its own rule, and the whole delete rolls back.
This is exactly the #696 / #711 construction — a set_null clear that leaves the holder in breach of its own rule — on a third object. It is NOT #720's construction (a hook refusing the write), and #720's fix does not reach it.
Measured
### lead after intake: duplicate_of_type=crm_contact duplicate_of_contact=crm_contact-… status=suspected is_converted=false
### DELETE-CONTACT-WITH-OPEN-DUPLICATE-LEAD: Duplicate Of Contact is required
### DELETE-ACCOUNT-WITH-OPEN-DUPLICATE-LEAD: Duplicate Of Contact is required
### dupe lead: duplicate_of_type=crm_lead duplicate_of_lead=crm_lead-…
### DELETE-SURVIVOR-LEAD: Duplicate Of Lead is required
Note the second line: because crm_contact.crm_account is a master-detail with deleteBehavior: 'cascade', the contact's ACCOUNT cannot be deleted either. Same GDPR "delete this person" impact as #696 / #711 / #720, and the same shape of unhelpful message — an error naming a field on an object the caller never addressed.
Reachability
Not dormant, and not rare: the flagging is automatic on the ordinary intake path, on a plain OPEN lead. No conversion, no freeze, no user action required — a lead that merely re-uses an existing contact's email is enough, which is precisely the case the dedupe exists to catch.
Suggested fix (not decided here)
Three candidates, in rough order of how well they match the intent:
- Clear the discriminator with the link. A
beforeUpdateoncrm_leadthat dropsduplicate_of_type(and arguablyduplicate_status) when the paired lookup is being nulled and nothing else is. Keepsset_null, keeps the pairing rule honest — the record simply stops claiming to duplicate anything once the thing it duplicated is gone. deleteBehavior: 'cascade'— wrong here, unlike 报名过市场活动的线索/联系人无法删除:级联把 campaign_member 的引用置空,随即违反它自己的校验规则 #696 / 参加过会议的线索/联系人/用户同样无法删除:crm_event_attendee 与 #696 是同一构造的同一缺陷 #711: those rows are junctions whose whole meaning is the reference, whereas a lead is a first-class record that happens to carry a flag. Deleting a contact would destroy the lead.- Loosen the
requiredWhento tolerate a missing lookup — this weakens a rule that is correct at authoring time, and re-opens the drift the pairing was declared to close.
(1) looks right, but the discriminator-plus-status question is a product call, so this is filed rather than fixed.
Refs #696, #711, #720. test/freeze-guard-reference-cleanup.test.ts (from #720) carries a boundary test pinning that none of the three freeze guards answers for this any more, which is where a fix should flip an assertion.
Generated by Claude Code
Activity
os-zhuang commentedon Aug 16, 2026
Claim: PM loop round 1
Session:
session_019TYoxKa8yFLiDDh7tkBqtuBranch:
claude/issue-1072-lead-duplicate-link-cleanupWorktree:
hotcrm-issue-1072Domain: (hotcrm has no
domain:*lane taxonomy — repo-wide seat)File surface:
src/objects/lead.object.ts,src/objects/lead.hook.ts,test/(stop on breach; explain in the report)Container & model:
M,mode:subagent,model: opusSerial constraints cleared: no open PR and no in-flight claim touches
src/objects/lead.*; the sibling dispatch this round (#1123) is confined tosrc/apps/crm.app.ts— disjoint.Triage: lands in hotcrm; rationale — the construction is entirely in this repo's authored metadata: a
set_nulllookup paired with arequiredWhendiscriminator that the null leaves in breach. The engine is behaving as specified.Adjudicated in the delegated lane, not escalated. The card says "the discriminator-plus-status question is a product call, so this is filed rather than fixed" — on review that framing is narrower than it looks, and holding the whole card for it would leave a GDPR-shaped defect open over a sub-question:
requiredWhenhonest, whereas the card's option (3) — loosening the rule — is exactly the lenient-consumer shape that hides authoring errors); startup scope discipline (no new capability).cascadeand (3) loosening are rejected with the card's own reasoning: a lead is a first-class record, and weakening a rule that is correct at authoring time re-opens the drift the pairing closed.The one genuinely open sub-question is delegated to measurement, not guessed: whether clearing
duplicate_of_typeshould also clearduplicate_status. Decide it by reading whatduplicate_statusmeans with no link left — if no view, filter, report or flow can act onsuspectedonce the survivor is gone, it is stale state and goes; if something does read it, keep it and say what. Report the reading either way.If the measurement contradicts the premise — e.g. a platform change since 17.0.0-rc.6 already clears the discriminator, or the delete now succeeds — stop and report
premise_still_valid: false. That is a valid delivery, not a failure.Generated by Claude Code
os-zhuang commentedon Aug 16, 2026
{ "issue": 1072, "status": "done", "branch": "claude/issue-1072-lead-duplicate-link-cleanup", "pr": "https://github.com/objectstack-ai/hotcrm/pull/1166", "premise_still_valid": true, "summary": "PM assumption 1 falsified in the safe direction: re-ran the card's probe on 17.0.0 GA and all four legs still reproduce (contact delete, account cascade, lead-lead, and the account above the contact), so the premise stands. PM assumption 2 verified: test/freeze-guard-reference-cleanup.test.ts does carry the #720 boundary test with the 'Duplicate Of Contact is required' assertion, and it is flipped here to a completed delete plus a cleared claim. Implemented the adjudicated option (1) in lead_duplicate_check: the discriminator may not outlive the lookup it names, so any beforeUpdate leaving the named lookup blank retires duplicate_of_type and duplicate_status in the same write. Two findings filed, plus one candidate falsified by measurement rather than filed.", "tests": "All runs serialized under flock /tmp/os-heavy-verify.lock. RED FIRST, on unmodified origin/main (fix committed first, then `git checkout origin/main -- src/objects/lead.hook.ts src/objects/lead.object.ts`, tests kept): `npx vitest run test/lead-duplicate-link-cleanup.test.ts test/freeze-guard-reference-cleanup.test.ts` => 'Test Files 2 failed (2) / Tests 10 failed | 42 passed (52)', including the flipped #720 boundary test and all six erasure-path cases. Restored with `git checkout <branch> -- <paths>`; `git diff HEAD --stat` empty afterwards, so the green numbers are from a tree byte-identical to the commit. GREEN: same two files plus lead-duplicate-management, converted-lead-guard, hooks-runtime-service => 'Test Files 5 passed (5) / Tests 202 passed (202)'. FULL GATE: `pnpm verify` (validate && typecheck && lint && lint:i18n-gate && hygiene && build && test) at HEAD 9a3ad8dc => 'Test Files 104 passed (104) / Tests 2552 passed | 1 skipped (2553)', VERIFY_EXIT=0. The trailing '✗ source hygiene failed: …' / '✗ i18n lint gate: …' lines are those gates' own captured self-test stdout, as the dispatch noted; the &&-chained exit is 0. No dogfood ablation applies (no packages/qa/dogfood in this repo, nothing resolves from dist/). Refusal assertions carry the measured envelope: ValidationError / code 'VALIDATION_FAILED' / status undefined, on top of the message wording.", "open_questions": [], "out_of_scope_findings": [ "filed as #1164: a lead already DISQUALIFIED as a duplicate still blocks the delete, now via duplicate_disqualification_requires_survivor instead of the field pairing — refused in both trees so not a regression from this PR, but its resting state ('closed as a duplicate of a person since erased') is a product call every candidate for which either loosens a rule correct at authoring time or rewrites a recorded business verdict; boundary pinned in this PR's test file", "filed as #1165 (finding label, no pm:queue): the engine's __referentialFieldClear marker IS reachable from a hook on 17.0.0 GA at ctx.api.executionContext — present on the cascade write, absent on a hand edit — which falsifies the load-bearing note in test/freeze-guard-reference-cleanup.test.ts that no marker reaches a hook; deliberately NOT read by this fix (operation-private __ key, and reachability through the shipped QuickJS path is unproven, so it could be green in the test rig and false in production)", "NOT filed, falsified by measurement: swept every other object for the same #696/#711 construction. One candidate, crm_quote.crm_contact (a set_null lookup carrying requiredWhen on status), turned out unreachable — contact_integrity refuses that delete first with a deliberate, helpful business message, on every quote status including the draft control" ] }Sub-question the card left open, answered by measurement, not by choice: clearing
duplicate_of_typealso clearsduplicate_status. Enumerated every reader of that field in the repo — thesuspected_duplicatesreview queue and its form block (src/views/lead.view.ts), theduplicate_disqualification_requires_survivorvalidation (lead.object.ts), the hook's own insert-time stand-down, and the four translation bundles. With no link left, none of them can act onsuspected: the queue's whole workflow is "open the two records and compare them" and the row would name nobody, while theconfirmedroute that drains the queue is closed by the validation the cleared discriminator satisfies. Leaving the flag would therefore park a permanently unworkable row in that queue rather than merely being inert. The verdict is not destroyed —duplicate_statusdeclarestrackHistory: true.Conflict to record: none between the dispatch prompt and the agent contract. The file surface held (
src/objects/lead.object.ts,src/objects/lead.hook.ts,test/, plus the mandatory.changeset/*.md);src/apps/crm.app.tswas never touched.Generated by Claude Code
os-zhuang commentedon Aug 16, 2026
ACCEPT — PR #1166. Landing gated on one job still running; see the bottom.
Verified against GitHub, not the report:
src/objects/lead.hook.ts,src/objects/lead.object.ts,test/lead-duplicate-link-cleanup.test.ts(new),test/freeze-guard-reference-cleanup.test.ts. All inside the declared surface (src/objects/lead.*,test/). Changeset present,Check Changesetgreen. No path touchesdocs/adr/**,.claude/skills/**orskills/**— normal landing path.needs-user-decision— see below) and The engine's reference-cleanup marker IS reachable from a hook on 17.0.0 GA — #720's "no marker reaches a hook" note is falsified, and three copies of a shape predicate rest on it #1165 (finding).crm_quote.crm_contactis unreachable becausecontact_integrityrefuses first on every status including the control case. A finding not filed, with the reason, is worth as much as one filed.What makes this the fix and not the loosening
The thing to check on this card was whether the suite went green by relaxing
requiredWhen. It did not, and the guard that prevents it is therestatedcheck: a payload carrying its own non-blankduplicate_of_typestands the retirement down, so an author's claim is judged by the rule rather than silently swallowed. That distinction is pinned by its own test ("refuses a write that STATES a type and blanks its lookup in one payload"), which also asserts the live claim survives the refusal — refused for real, not merely announced.The measurement that made the design decision
The dispatch asked for one sub-question to be measured, not guessed. It was measured harder than asked:
duplicate_statusgoes with the link — every reader in the repo enumerated, each shown unable to act onsuspectedwith no link left. The decisive argument is not "nothing reads it" but that leaving it would park a permanently unworkable row in thesuspected_duplicatesqueue: it names nobody to compare against, and theconfirmedroute that drains the queue is closed by the very rule the cleared discriminator satisfies. The verdict survives in field history (trackHistory: true).input,ctx.userandctx.session, and the survivor is still readable from both. Asking a question the context cannot answer is what forces shape-sniffing. The resulting trade — a hand-clear also retires the claim — is pinned as a named test so a later reader sees a measured decision rather than an oversight.lead_automation(200), the converted-lead lock would refuse the very cleanup 冻结/锁定守卫会拒绝引擎的引用清理:被冻结的记录让它引用的人和客户永远删不掉 #720 fixed it to allow, becauseduplicate_of_type/duplicate_statusare not lookups going value→null.Red before green: 10 failures across two files on unmodified
origin/main, including the flipped #720 boundary assertion. Refusals assert the measured envelope (ValidationError/code: 'VALIDATION_FAILED'/status: undefined) rather than baretoThrow().#1164 labelled
needs-user-decisionCorrectly framed by the dev as out of scope: the delete is refused in both trees, so this PR changes which rule answers, not whether the delete succeeds. "Closed as a duplicate of a person since erased" has no obviously right resting state, and all three candidates either loosen a rule correct at authoring time (the thing this card rejected), rewrite a recorded business verdict, or add a capability. That is a product call, and the boundary is pinned in this PR's test file where a fix should flip it.
#1165 — the restraint is the right call
The engine's
__referentialFieldClearmarker turns out to be reachable atctx.api.executionContext, which falsifies a load-bearing note in #720's test file. The dev did not read it, and the reasoning holds: it is an operation-private__key, and hook bodies run body-only inside QuickJS wherectx.apimay be a shim carrying noexecutionContextat all — so a predicate reading it could be green in the kernel rig and false in production. Finding the key and declining to use it, with the reason written down, is the correct outcome.Landing status
8 of 9 checks
completed: success.Build and Test (22.x)is stillin_progress— same gate job that was last to converge on #1163. The PR stays draft until it reportssuccess; it will be marked ready and queued on the next check-in, or diagnosed if it turns red.Generated by Claude Code