Refuse conversion of a confirmed-duplicate lead - #1555
Merged
os-sales merged 2 commits intoSep 3, 2026
Conversation
`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
|
The latest updates on your projects. Learn more about Vercel for GitHub. |
os-sales
marked this pull request as ready for review
September 3, 2026 11:42
This was referenced Sep 3, 2026
This was referenced Sep 6, 2026
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>
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.
Fixes #1288
Executes ruling C, 2026-08-31 (maintainer 「其他同意」, hotcrm 决裁批 #19 ①): a lead whose
duplicate_statusisconfirmedis refused conversion; a lead flaggedsuspectedkeeps #1207's warn-and-allow.Verified on
4d097f76:pnpm verifygreen 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/spec17.2.0, the action predicates cannot do the second half:Action.visibleboolean | CEL envelopeAction.disabledboolean | CEL envelopeAction.errorMessagescreen.descriptionThe flow is also the only choke point that covers every door: the record-header button, the list-row button and the
action_convert_leadAI tool all dispatchPOST /automation/lead_conversion/trigger, whilevisible/disabledare console-side and say nothing to the other two.So the refusal is a message-only
screennode,refuse_confirmed_duplicate, reached by a newConfirmededge (e25) out ofdecision_duplicateand leading straight toend. Nothing in this flow writes beforescreen_1, so the refusal branch cannot create anything.Measured on the shipped console bundle (
@objectstack/console17.2.0,RecordDetailViewflow handler): a run that PAUSES returns{ success: true, silent: true }, andsilentsuppresses the success toast — soconvert_lead'ssuccessMessage: 'Lead converted successfully!'does not fire behind this dialog.The one-line change that is not cosmetic
e22("Clean") was!= "suspected". Adecisionnode that declares noconfig.conditionsreports no branch, so traversal takes every out-edge whose condition holds, in parallel. Left as it was, a confirmed lead satisfiede22ande25— 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
The verdict is named in the vocabulary the record publishes (
duplicate_status's label is "Duplicate Status", itsconfirmedoption's label is "Confirmed", in all four locales). The survivor is named through the relationship fields that exist to carry it — theduplicatesfield group, by its shipped label "Duplicate Management" — which is the house rule oftest/record-id-not-in-prose.test.ts, and the only claim that stays true on theerasedtombstone.suspectedwarning uses. That claim is safe there because onlylead_duplicate_checkwritessuspectedand it matches on email.confirmedis written by a person, and the lead form lets a reviewer pointduplicate_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_typeis 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.suspectedconverted)e21at the refusal node (option B) → 4 redconfirmedis_converted: false, status stillqualified; all three survivors (lead · contact ·erasedtombstone)e25back atno_duplicate_warning(the before-picture) → 5 rede22at the refusal node → 4 rede22to!= "suspected"→ 2 red,expected [ 'e25', 'e22' ] to have a length of 1 but got 2Every 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 HEADempty, blob hash back to its HEAD value) rather than by an exit code. One of the four initially reportedMUTATION DID NOT REACH DISKon a mis-escaped anchor and its reading was discarded rather than trusted.Before-picture, measured on the parent commit
Through the same harness,
confirmedleads suspended onscreen_1with no description at all (the #1207 warning is gated onsuspected) and then converted: one account, one contact, one opportunity,is_converted: true. All three ofcrm_lead/crm_contact/erasedsurvivors did. That is the behaviour the changeset announces changing.Not touched
src/translations/**— flow copy has no locale channel in this repo (noflowskey in any pack;lead_conversionappears 0 times in all four). Action copy is localized, but no action string changed.src/objects/lead.hook.tsandsrc/objects/lead.object.ts— out of surface.Docs
The three
content/docs/sales/leads*.mdxlocales 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