Skip to content

feat(lead): one duplicate banner per verdict, each with its own next step - #1684

Merged
os-steve merged 1 commit into
mainfrom
claude/issue-1628-duplicate-banner-per-verdict
Sep 6, 2026
Merged

feat(lead): one duplicate banner per verdict, each with its own next step#1684
os-steve merged 1 commit into
mainfrom
claude/issue-1628-duplicate-banner-per-verdict

Conversation

@os-steve

@os-steve os-steve commented Sep 6, 2026

Copy link
Copy Markdown
Collaborator

Closes #1628

Splits the lead detail page's duplicate banner into one record:alert per verdict, so each one states the next step its verdict actually has. No change to what the app refuses.

Why one banner could not state either next step

#1207 gated a single banner on duplicate_status == "suspected"; #1289 widened it to every verdict. Widening was right — but a record:alert carries one visible and one title/body pair, and pickLocalized picks by LANGUAGE, not by row. One component covering both verdicts therefore had to word itself so it named neither, or it would have mislabelled every lead in the other state.

Since #1288 the verdicts have opposite next steps:

verdict at conversion what the rep should do
suspected warns, conversion proceeds compare against the linked record, then convert or disqualify
confirmed refused (refuse_confirmed_duplicate) cannot convert — disqualify, naming the survivor

So the neutral banner announced that something was wrong without saying what to do. On confirmed it was worse: nothing on the record said Convert would refuse them, so they learned it by pressing it.

What ships

id verdict severity says
lead_duplicate_alert_suspected suspected warning compare first; you can still convert
lead_duplicate_alert_confirmed confirmed error conversion will be refused; disqualify and name the survivor

All four locales (en / zh-CN / ja-JP / es-ES), plus the component label keys in src/translations/*/app.ts. lint:i18n-gate reports 0 i18n/missing-*.

error is not decoration: RecordAlertProps documents, and record-alert.tsx implements, role="alert" / aria-live="assertive" for error against the polite role="status" every other level gets. suspected stays warning because conversion still goes through.

Two sibling record:alert nodes really do both render — verified before building on it

The whole plan rests on this, so it was measured on the pinned 17.3.0 bundle, not assumed. RegionContent in node_modules/@objectstack/console/dist/assets/ui-components-*.js at the .objectui-sha pin (00d3f09c):

let n = e.components || [];
return n.length === 0 ? null : jsx(`div`, { ..., "data-region": e.name,
  children: n.map((t,n) => jsx(ve, { schema: we(t) }, t?.id || `${e.name}-${n}`)) })

Every component is mapped to its own renderer, so both banners mount and each evaluates its own visible against the same row. The React key is node.id — which is why the two ids differ rather than sharing one, and why that is now pinned.

The predicate: guard kept verbatim, comparison narrowed

⚠️ Flagging this explicitly because the dispatch's Zone 1 text names the spelling has(x) && x != null.

Both halves of the shape #1289 ruled for are intact — the has() guard verbatim, and a comparison beside it that makes "set" mean set. What changed is that the comparison became the equality, which is strictly narrower than != null and subsumes it. Measured on @objectstack/formula 17.3.0:

record shape has && != null has && == "suspected" has && != null && == "suspected"
{} (driver-memory / mongodb) false false false
{duplicate_status: null} (driver-sql clean) false false false
suspected true true true
confirmed true false false
merged true false false

The != null term is a provable no-op beside the equality — identical verdicts on all five shapes — and null == "suspected" is a clean false on this engine, not a fault. A dead term on a fail-soft surface is worse than no term: it reads as load-bearing to the next person. The repo's own precedent settles the spelling — the conversion flow's e21 / e25 edges (#1288), on this same field, already read has(vars.leadRecord.duplicate_status) && … == "suspected".

⛔ Neither half is simplified away, and both remain pinned: bare has() is still TRUE for a present-and-null key (re-measured), and the unguarded tail still faults on a keyless record.

The pins that moved, and why — deliberately, one by one

test/lead-duplicate-visibility.test.ts, no new test file, no new gate (AGENTS.md:431).

  1. ships a warning-severity record:alertships ONE record:alert per verdict, under distinct ids + a per-verdict severity pin. The old pin asserted a single alert at one severity; there are now two, at two severities. The distinct-id half is new and load-bearing (React keys, above).
  2. answers with a VERDICT on every record shape → per-banner, table-driven: each banner is true on exactly its own verdict across all five shapes. The old pin's row 4 asserted the widened banner was true on confirmed; that row now belongs to the confirmed banner.
  3. at most one banner is ever shown — NEW. The record-page twin of the flow's "exactly one live edge" pin. Two banners on one row would stack two contradictory next steps; nothing structural prevents it, so it is measured.
  4. the guard is load-bearing → per-banner. ⭐ The property A confirmed duplicate that is not yet disqualified gets no banner and no conversion warning — #1207's predicates are suspected-only because my ruling said so #1289 built in is preserved: the unguarded text is still built from the shipped predicate itself (source.split('&&').pop()), never typed out. What splitting changed is what that tail says — it used to be != null, and is now == "this banner's verdict". So the second leg now asks for the banner's own verdict row: the unguarded == "confirmed" tail is correctly false on a suspected lead, and asserting true there would pin the wrong claim. It holds for both predicates.
  5. describes the flag without asserting WHICH verdict it isnames ITS OWN verdict, and never the other one. This is the inversion. One banner shown on both states could assert neither; a banner shown on exactly one state must name that state — that is the card. It must still never name the other, for exactly the reason A confirmed duplicate that is not yet disqualified gets no banner and no conversion warning — #1207's predicates are suspected-only because my ruling said so #1289 gave. ⭐ Both words are still READ FROM the locale packs, never typed here, so renaming an option re-aims both assertions instead of retiring them.

Unchanged: names the record it repeats and every lead_conversion block.

One behaviour change, deliberate

A lead carrying a value neither option declares (e.g. merged) now raises no banner, where the widened predicate raised the neutral one. The conversion flow's e22 Clean edge already treats such a row as clean and converts it — so the page and the flow now agree about the same lead instead of contradicting each other.

Evidence

Red before green. Pins changed first, predictions written down, then run against the unchanged page: 18 failures predicted, 18 observed, name for name (1 shape + 2 severity + 2 envelope + 2 verdict-table + 5 exclusivity + 2 guard + 2 locale + 2 copy); the other 20 stayed green. After the page change: 38/38.

Ablation (from the committed implementation; mutation proven on disk by anchor counts and blob hash, restored and proven by an empty git diff HEAD). Widening the confirmed predicate back to != null produced exactly the 3 predicted failures:

× the confirmed banner answers with a verdict on every record shape
× at most one banner is ever shown — suspected
× the confirmed banner's guard is load-bearing — the unguarded spelling really does fault

pnpm verify fully green — all eight stages: validate · typecheck · lint · lint:i18n-gate (0 i18n/missing-*) · hygiene · hygiene:tokens · build · test (161 files, 3414 passed, 1 skipped). Token ratchet clean: interaction layer 37,366 → 37,963 (ceiling 40,000).

Out of scope


Generated by Claude Code

…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>
@vercel

vercel Bot commented Sep 6, 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 6, 2026 10:14am UTC

Request Review

@github-actions github-actions Bot added ci/cd CI plumbing and the verification pipeline metadata Declarative metadata — schema, security posture, UI surfaces labels Sep 6, 2026
@os-steve
os-steve marked this pull request as ready for review September 6, 2026 10:18
@os-steve
os-steve added this pull request to the merge queue Sep 6, 2026
Merged via the queue into main with commit 8dccbbb Sep 6, 2026
10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ci/cd CI plumbing and the verification pipeline metadata Declarative metadata — schema, security posture, UI surfaces

Projects

None yet

Development

Successfully merging this pull request may close these issues.

The duplicate banner now covers both verdicts with one neutral message — but suspected and confirmed have opposite next steps, so it states neither

2 participants