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

@github-actions github-actions Bot added ci/cd CI plumbing and the verification pipeline backend Server-side behaviour — hooks, flows, actions labels Sep 3, 2026
@os-sales
os-sales marked this pull request as ready for review September 3, 2026 11:42
@os-sales
os-sales added this pull request to the merge queue Sep 3, 2026
Merged via the queue into main with commit f9a9d1b Sep 3, 2026
11 checks passed
os-steve pushed a commit that referenced this pull request Sep 6, 2026
…step

`src/pages/lead_detail.page.ts` carried ONE `record:alert` for both duplicate
verdicts. #1207 gated it on `duplicate_status == "suspected"`; #1289 widened it
to every verdict the field carries. The widening was right, but a `record:alert`
carries a single `visible` and a single title/body pair, and `pickLocalized`
picks by LANGUAGE, not by row — so one component covering both verdicts had to
word itself so it named NEITHER, or it would have mislabelled every lead in the
other state.

Since #1288 the two verdicts have opposite next steps: `suspected` warns and
conversion PROCEEDS, `confirmed` is REFUSED outright by
`refuse_confirmed_duplicate`. One sentence cannot state either without being
false for the other, so it stated neither — the banner announced that something
was wrong without saying what to do, and the rep had to scroll to the Duplicate
Status chip to find out which situation they were in. On `confirmed` it was
worse: nothing on the record said the Convert button would refuse them, so they
learned it by pressing it.

So the banner becomes two, one per verdict, in all four locales. `suspected`
keeps `warning` and says to compare against the linked record first; `confirmed`
ships at `error` — which `record-alert.tsx` maps to `role="alert"` /
`aria-live="assertive"` rather than the polite `role="status"` every other level
gets — and is the only place a rep is warned about the refusal before pressing
Convert.

Two sibling `record:alert` nodes really do both render: a region renders as
`components.map((node, i) => <SchemaRenderer key={node?.id || fallback} …>)`,
read out of the shipped console bundle at the `.objectui-sha` pin, so each is
mounted separately and evaluates its own `visible` against the same row. Their
ids are their React keys, which is why the two ids differ.

Both halves of the guard #1289 ruled for are intact. `has()` stays verbatim —
this call site is FAIL-SOFT, so an unevaluable predicate SHOWS the banner, and
an unguarded predicate aborts with `No such key` on every clean lead whose
driver omits the column. The comparison beside it becomes the EQUALITY, which is
strictly narrower than `!= null` and subsumes it: measured on the pinned engine,
`null == "suspected"` is a clean `false`, not a fault, and the two spellings
agree on every record shape a driver can produce. The same spelling already
ships on this field one file over, in the conversion flow's `e21` / `e25` edges.

A lead carrying a value neither option declares now raises NO banner, where the
widened predicate raised the neutral one. That matches what the flow already
does with such a row — `e22` Clean converts it — so the page and the flow now
agree about the same lead.

Nothing here changes what the app refuses; that was ruled by #1288 and shipped
by PR #1555.

The pins in `test/lead-duplicate-visibility.test.ts` move deliberately, three of
them: the shape (one alert -> two, addressed by id, ids distinct), the predicate
table (each banner true on exactly its own verdict, plus a new mutual-exclusion
pin mirroring the flow's one-live-edge rule), and the copy rule, which INVERTS
from "names neither verdict" to "names its own and never the other". The copy
rule still reads both words out of the locale packs, so renaming an option
re-aims the assertion instead of retiring it.

Claude-Session: https://claude.ai/code/session_018xtjdpZFjgWh4Ad9Wcx68J

Co-authored-by: Claude <noreply@anthropic.com>
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