Skip to content

Refuse conversion of a confirmed-duplicate lead - #1555

Merged
os-sales merged 2 commits into
mainfrom
claude/issue-1288-confirmed-duplicate-blocks-conversion
Sep 3, 2026
Merged

Refuse conversion of a confirmed-duplicate lead#1555
os-sales merged 2 commits into
mainfrom
claude/issue-1288-confirmed-duplicate-blocks-conversion

Conversation

@os-sales

@os-sales os-sales commented Sep 3, 2026

Copy link
Copy Markdown
Collaborator

Fixes #1288

Executes ruling C, 2026-08-31 (maintainer 「其他同意」, hotcrm 决裁批 #19 ①): a lead whose duplicate_status is confirmed is refused conversion; a lead flagged suspected keeps #1207's warn-and-allow.

Verified on 4d097f76: pnpm verify green end to end — validate · typecheck · lint · lint:i18n-gate · hygiene · hygiene:tokens · build · test (159 files, 3359 passed, 1 skipped).

The layer, and why

The ruling allowed either door — 「谓词或 flow 拒绝」 — and also required the refusal copy to name the verdict and the surviving record. Measured against @objectstack/spec 17.2.0, the action predicates cannot do the second half:

candidate shape can it carry a sentence about this lead?
Action.visible boolean | CEL envelope no — it hides the button, and a hidden button cannot explain itself
Action.disabled boolean | CEL envelope no — no reason string of any kind
Action.errorMessage one static string no — same text for every lead and every failure
flow screen.description interpolated per run, rendered as the dialog body yes — which is why #1207's warning already lives on one

The flow is also the only choke point that covers every door: the record-header button, the list-row button and the action_convert_lead AI tool all dispatch POST /automation/lead_conversion/trigger, while visible / disabled are console-side and say nothing to the other two.

So the refusal is a message-only screen node, refuse_confirmed_duplicate, reached by a new Confirmed edge (e25) out of decision_duplicate and leading straight to end. Nothing in this flow writes before screen_1, so the refusal branch cannot create anything.

Measured on the shipped console bundle (@objectstack/console 17.2.0, RecordDetailView flow handler): a run that PAUSES returns { success: true, silent: true }, and silent suppresses the success toast — so convert_lead's successMessage: 'Lead converted successfully!' does not fire behind this dialog.

The one-line change that is not cosmetic

e22 ("Clean") was != "suspected". A decision node that declares no config.conditions reports no branch, so traversal takes every out-edge whose condition holds, in parallel. Left as it was, a confirmed lead satisfied e22 and e25 — the refusal would have shown and the lead would have converted in the same run. It now excludes both verdicts, and the exclusivity is pinned on the real evaluator across all seven record shapes a driver can produce.

What the rep sees

This lead's Duplicate Status is Confirmed: a reviewer compared it against an existing record and recorded that it repeats one. Converting would create a second account, contact and opportunity for the same buyer. The Duplicate Management section on this lead names the surviving record; disqualify this lead as a duplicate instead. Only a reviewer revising that verdict reopens conversion.

The verdict is named in the vocabulary the record publishes (duplicate_status's label is "Duplicate Status", its confirmed option's label is "Confirmed", in all four locales). The survivor is named through the relationship fields that exist to carry it — the duplicates field group, by its shipped label "Duplicate Management" — which is the house rule of test/record-id-not-in-prose.test.ts, and the only claim that stays true on the erased tombstone.

⚠️ Deliberately not the shared email address, which is what the suspected warning uses. That claim is safe there because only lead_duplicate_check writes suspected and it matches on email. confirmed is written by a person, and the lead form lets a reviewer point duplicate_of_type + its lookup at any record they like — so the email is not guaranteed to be shared.

⛔ No override hatch is offered, and the vocabulary of duplicate_of_type is deliberately not transcribed into the sentence ("an existing Lead" / "an existing Contact"): those are locale-pack facts with one source of truth, visible on the section this line points at, and a hand-copied machine list in prose is the drift AGENTS.md documentation rule 5 forbids.

Three states, pinned — with the red produced before each green

test/lead-duplicate-visibility.test.ts. Each pin resumes the run and then counts what the store holds: a paused run has created nothing on any branch yet, so a pin that stopped at the pause would be green against a flow with no refusal in it.

state pinned behaviour ablation that produced the red
suspected warns, then converts (1 account / 1 contact / 1 opportunity, lead converted) point e21 at the refusal node (option B) → 4 red
confirmed refuses — 0 / 0 / 0, is_converted: false, status still qualified; all three survivors (lead · contact · erased tombstone) point e25 back at no_duplicate_warning (the before-picture) → 5 red
verdict cleared converts again, with the survivor link still on the record point e22 at the refusal node → 4 red
the partition exactly one live edge on all seven record shapes restore e22 to != "suspected" → 2 red, expected [ 'e25', 'e22' ] to have a length of 1 but got 2

Every ablation proved its mutation reached disk (anchored counts on removed and injected text, plus a blob hash differing from the HEAD blob), and every restore was proved by state (git diff HEAD empty, blob hash back to its HEAD value) rather than by an exit code. One of the four initially reported MUTATION DID NOT REACH DISK on a mis-escaped anchor and its reading was discarded rather than trusted.

Before-picture, measured on the parent commit

Through the same harness, confirmed leads suspended on screen_1 with no description at all (the #1207 warning is gated on suspected) and then converted: one account, one contact, one opportunity, is_converted: true. All three of crm_lead / crm_contact / erased survivors did. That is the behaviour the changeset announces changing.

Not touched

  • src/translations/** — flow copy has no locale channel in this repo (no flows key in any pack; lead_conversion appears 0 times in all four). Action copy is localized, but no action string changed.
  • src/objects/lead.hook.ts and src/objects/lead.object.ts — out of surface.

Docs

The three content/docs/sales/leads*.mdx locales described Convert as always available on a qualified lead. Each now says, under its own conversion heading, that a Confirmed duplicate opens a refusal instead — using the language pack's own wording for the field, its Confirmed option and the Duplicate Management group.

🤖 Generated with Claude Code

https://claude.ai/code/session_019hUuCQStzXGMFSX4dzww5t


Generated by Claude Code

`lead_conversion` now stops on a lead whose `duplicate_status` is
`confirmed` — a verdict a person recorded — and shows a refusal naming
that verdict and where the surviving record is linked. The machine's
`suspected` guess keeps #1207's warn-and-allow: `lead_duplicate_check`
matches on email equality, so shared inboxes false-positive by
construction, and blocking on a guess would need an override flag.

The refusal is a screen node rather than the action's `visible` /
`disabled` predicate: those are bare booleans with nowhere to put a
sentence, and the ruling requires the refusal to name the verdict and
the survivor. The flow is also the only choke point the record-header
button, the list-row button and the `action_convert_lead` AI tool all
pass through.

`e22` ("Clean") is narrowed in the same change, and that half is
load-bearing: a `decision` node with no declared conditions takes every
out-edge whose condition holds, so the old `!= "suspected"` spelling
would have shown the refusal and converted the lead in one run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019hUuCQStzXGMFSX4dzww5t
The three locale pages describe Convert as always available on a
qualified lead. It now opens a refusal on a lead whose Duplicate Status
is Confirmed, so each page says so under its own conversion heading,
using the language pack's own wording for the field, its Confirmed
option and the Duplicate Management group.

Adds the changeset: this is a behaviour change, and it announces that
the app begins refusing conversion of confirmed-duplicate leads.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019hUuCQStzXGMFSX4dzww5t
@vercel

vercel Bot commented Sep 3, 2026

Copy link
Copy Markdown

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

1 Skipped Deployment
Project Deployment Actions Updated
hotcrm Ignored Ignored Sep 3, 2026 11:39am UTC

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Decision] Should converting a lead flagged duplicate_status: suspected be blocked, or only warned about?

2 participants