Repository navigation
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
Description
Follow-up to #1207 (PR #1286, 413d9640). Filed by the repo:hotcrm PM seat, and the cause is the PM's ruling, not the dev's implementation — recorded that way on purpose.
What is narrow, and why
#1207 shipped two surfaces gated on has(record.duplicate_status) && record.duplicate_status == "suspected":
- the
record:alertbanner onsrc/pages/lead_detail.page.ts - the per-run warning line on
src/flows/lead-conversion.flow.ts's conversion screen
A lead carrying confirmed — a human verdict, stronger evidence than suspected — that has not yet been disqualified raises the same hazard and gets neither.
B is a one-word change to two predicates and I would have written it that way; I did NOT, because the ruling said
suspected-conditional and a ruling is not mine to widen.
My dispatch ruling on #1207 said "a suspected-conditional banner". I wrote the narrower thing without thinking about confirmed, and the dev honoured it rather than quietly improving it. The discipline is right; the ruling was not.
How narrow it actually is
Not a live defect, and the card should not be dispatched as one:
- The Duplicate Management details section A lead flagged
duplicate_status: suspectedlooks clean on its record page, andConvert Leadnever mentions it — the duplicate converts into a second account, contact and opportunity #1207 added renders for any duplicate state — includingconfirmedand theerasedtombstone — so the record page is not silent, only the banner is. - The usual next step after
confirmedis disqualification, and the convert action'svisiblepredicate excludesstatus: unqualified, so the conversion warning's blind spot is the window between a human confirming and a human disqualifying.
⇒ A small, deliberate hole rather than a live hazard. It is being filed rather than left because it is exactly the kind of boundary that is invisible six months later.
Scope
Widen both predicates from == "suspected" to any set duplicate_status — has(record.duplicate_status) — with banner and warning copy that reads correctly for both states (a confirmed duplicate should not be described as "suspected"). All four locales.
test/lead-duplicate-visibility.test.ts (the predicate is asserted to answer a verdict on suspected and on confirmed), so widening is a deliberate edit that must update the pins — not silent drift. Read those pins first; they encode the current boundary on purpose.
⛔ Not in scope: whether conversion should be blocked on either state. That is #1288, in the decision inbox.
has(...) is load-bearing on both surfaces and they fail in opposite directions — the page predicate is fail-soft (an unevaluable predicate SHOWS the banner on every clean lead) and the flow predicate fails the whole run (an ordinary lead becomes unconvertible). #1207's ablation measured both with the engines' own words. ⛔ Do not simplify the guard away while touching the comparison.
Related
- A lead flagged
duplicate_status: suspectedlooks clean on its record page, andConvert Leadnever mentions it — the duplicate converts into a second account, contact and opportunity #1207 / PR Surface the suspected-duplicate flag on the lead record page and at conversion #1286 — where the boundary was set - [Decision] Should converting a lead flagged
duplicate_status: suspectedbe blocked, or only warned about? #1288 — the block-vs-warn product question - The predicate-totality sweep cannot see the
daysBetween(null, …)abort — it greps ordering operators, and its totality run short-circuits before the call #1115 — the predicate-totality guard gap; this pair of predicates is a worked example of the hazard it is about
Activity
os-steve commentedon Sep 6, 2026
Claim: PM loop round 39⚠️ ⚠️ #1342 was re-graded to
Session:
session_018xtjdpZFjgWh4Ad9Wcx68JBranch:
claude/issue-1289-duplicate-banner-covers-confirmedWorktree:
hotcrm-issue-1289Domain:
repo:hotcrm(single-lane repo — nodomain:*prerequisite)File surface:
src/pages/lead_detail.page.ts(therecord:alertpredicate) ·src/flows/lead-conversion.flow.ts(the warning-line predicate) ·src/translations/*.ts×4 (banner + warning copy) ·test/lead-duplicate-visibility.test.ts(the two pinned boundaries) ·.changeset/. ⛔ Nothing else. (stop on breach; explain in the report)Container & model:
M,mode:subagent,model: opus(default judgement tier). ⛔ Not the floor tier: the copy must read correctly for two states, and thehas(...)guards fail in opposite directions.Clause-②: no
Serial constraints cleared: all five surfaces free — no in-flight claim, no queued sibling.
src/translations/*.tshas queued siblings #1262 · #1311 · #1342; none is dispatched and ⛔ none may be while this card holds the file.needs-user-decisionthis round, so it is out of the queue entirely.⭐
test/**is NOT fenced right now — established this round, ⛔ do not inherit the old fenceEpic #1579's
test/**reservation has been read as a blanket fence in earlier rounds. Measured at 01:4xZ:os lint --strictguards them (epic #1579, step 3, family F1) #1582–Retire the local hook / action / flow write-shape tests the platform's *-body-write-* and flow-node-write rules already enforce (epic #1579, step 3, family F6) #1587, Replace the hand-built hook / flow / action harnesses with@objectstack/verify's in-process handle; delete the stand-ins and the suites that only prove the stand-ins; declare the platform packages tests import (epic #1579, step 5) #1595) arepm:epicreserved but undispatched ⇒ no live write claim on any test file.test/lead-duplicate-visibility.test.tsis not among the epic's nine F1 candidate files (those areflow-decision-authority,view-tab-label-inert,readonly-write-semantics,flow-filter-today-token,view-predicate-dialect,object-validation-predicates,flow-condition-totality,flow-variable-conditions,line-item-conventions).⇒ this card's test edit is a deliberate boundary update on a business-fact pin, ⛔ not a retirement, and it does not touch the epic's candidate set. ⛔ Do not edit any other
test/**file.Premise — the card is unusually self-aware; read it whole
⭐ The card records that the cause was the PM's own ruling, not the dev's implementation, and quotes the #1207 dev refusing to widen it: "a ruling is not mine to widen." That discipline was right and the ruling was wrong. This card is the correction.
visibleexcludesstatus: unqualified, so the conversion warning's blind spot is only the window between a human confirming and a human disqualifying.⛔ Out of scope: whether conversion should be blocked on either state — that is #1288, in the decision inbox.
Generated by Claude Code
os-steve commentedon Sep 6, 2026
os-dev-report
{ "issue": 1289, "status": "done", "branch": "claude/issue-1289-duplicate-banner-covers-confirmed", "pr": "https://github.com/objectstack-ai/hotcrm/pull/1626", "base_sha": "ff4332161745a34c5eb2a45a95ccbfc8bf902ca4", "head_sha": "1ba84aed8075f4786b8937f3e7b53c6b31c30cac", "premise_still_valid": false, "summary": "Ruling 1 has two bullets. Bullet 1 (the record:alert banner on lead_detail.page.ts) was valid at my base and is DELIVERED: the predicate now answers on any verdict the record carries, and the banner copy was rewritten in all four locales so it names neither state. Bullet 2 (the per-run warning line on lead-conversion.flow.ts) is FALSIFIED and I did not do it. #1288 — which Zone 1 ruling 5 lists as still in the decision inbox — has been decided, implemented and documented; the flow's decision node now has three out-edges (e21 suspected warns, e25 confirmed REFUSES, e22 clean), so a confirmed duplicate at conversion already gets a refusal, which is strictly more than the warning the ruling asked for. Widening e21 as instructed would have been actively harmful, not merely stale: a decision node declaring no config.conditions takes EVERY out-edge whose condition holds, in parallel, so a confirmed lead would have matched e21 AND e25 and been warned-and-converted alongside the refusal — silently reverting #1288 and deciding the block-vs-warn question in the reverse direction. The flow file's own comment records that exact non-partition being measured. PR is Part of, not Fixes, so the merge does not close the card before a person reads this.", "premise_check": { "sanitizer_truncation": "none — issue #1289 body and the claim comment 5556161441 both read complete; the body closes on its Related list, the claim on its footer. Nothing elided.", "predicate_page": "HELD. `has(record.duplicate_status) && record.duplicate_status == \"suspected\"` at ff433216, confirmed by a red run before I changed it.", "predicate_flow": "GONE. e21 is still suspected-only, but it is no longer the only branch: e25 routes confirmed to refuse_confirmed_duplicate, and e22 was narrowed to carry both inequalities. There is no confirmed blind spot left on that surface to widen into.", "details_section_renders_for_any_state": "HELD — the duplicates section is unchanged and still renders on any duplicate state.", "convert_visible_excludes_unqualified": "HELD, but now moot for confirmed: the flow refuses before any write regardless of status.", "erased_tombstone": "MISREAD IN THE DISPATCH, and it makes the copy job smaller. `erased` is a DUPLICATE_OF_TYPE value (DUPLICATE_OF_TYPE_ERASED in src/objects/_picklists.ts), not a duplicate_status value. duplicate_status declares exactly two options, suspected and confirmed, so has(...) widens to two states, not three, and no copy has to cover a tombstone." }, "the_two_guards": { "page_predicate": "STILL GUARDED, fails FAIL-SOFT. record:alert's visible goes through useCondition at a call site where an unevaluable predicate answers SHOWN — so an unguarded spelling puts a duplicate warning on every clean lead. has(...) is untouched and still stands in front of the comparison.", "flow_predicate": "STILL GUARDED, fails HARD — the whole run fails, making an ordinary lead unconvertible. Untouched by this PR; I edited no flow file.", "measured_not_recalled": "the load-bearing pin derives the unguarded spelling FROM the shipped predicate by splitting on '&&' and watching it fault, so it cannot drift from what actually ships. It still passes, and it now also catches a predicate that has become guard-only." }, "ruling_1_deviation_measured": { "what": "the ruling prescribes has(record.duplicate_status) as the widened predicate. Shipped instead: has(record.duplicate_status) && record.duplicate_status != null.", "why": "has() is TRUE for a key that is PRESENT AND NULL, which is exactly what driver-sql returns for a clean lead. The bare form would show the banner on every clean lead on that driver — the same cry-wolf failure as dropping the guard, reached by the other road.", "measured_on_objectstack_formula": { "has(record.duplicate_status) vs {duplicate_status: null}": "{ ok: true, value: true }", "has(record.duplicate_status) && record.duplicate_status != null vs {duplicate_status: null}": "{ ok: true, value: false }", "record.duplicate_status != null vs {} (keyless)": "{ ok: false, error: runtime 'No such key: duplicate_status' } — so the guard is still load-bearing" } }, "gates": { "pnpm verify": "PASS — VERDICT command-exit 0, held the lock 161s. All eight stages ran: validate, typecheck, lint, lint:i18n-gate, hygiene, hygiene:tokens, build, test.", "test": "160 test files passed (160); 3395 passed, 1 skipped (3396).", "lint:i18n-gate": "PASS — '0 i18n/missing-* issues (29 total lint issue(s) reported, unaffected by this gate)'.", "hygiene": "PASS — 'source hygiene clean'; no control bytes, no id-in-prose, no file over 100KB.", "hygiene:tokens": "PASS — 'source token ratchet clean'. business semantics ~84,838 of ~100,000; interaction layer ~37,572 of ~40,000; authored total ~136,751 of ~140,000. NOT cited as evidence the copy is right — it cannot see copy quality, and src/translations is outside the ratchet by ruling anyway.", "changeset": ".changeset/duplicate-banner-covers-every-verdict.md added by this PR, real frontmatter ('hotcrm': patch) — user-visible copy change. No site-absolute links, so link-check has nothing to bite on.", "run_at": "the verify run is at head 1ba84aed, after both ablation legs restored — the restore was proved byte-identical by blob hash (29f9156c...), so the green covers the exact shipped tree.", "ci": "in_progress — reported at draft-PR time as instructed; CI convergence is the PM's to read." }, "red_before_green": { "requested": "prove the widened predicate actually fires on confirmed, not merely that the suite is green", "leg_1_red": "pins updated FIRST, predicate untouched: 2 failed | 24 passed. 'AssertionError: expected { ok: true, value: false } to deeply equal { ok: true, value: true }' on the confirmed shape, and 'the en banner copy says \"Suspected\"' on the new copy pin.", "leg_2_green": "after the widening and nothing else: 26 passed (26).", "ablation": "with the implementation COMMITTED, the shipped predicate was mutated to the bare has(...) the ruling prescribes. Mutation proved on disk (HEAD blob 29f9156c -> f8d32cfd, anchor count 1 -> 0, mutated line printed); suite went 2 failed | 24 passed — the driver-sql null lead started showing the banner, and the load-bearing pin reported the guard had become the whole predicate. Restore proved by hash back to 29f9156c.", "void_first_attempt": "my first ablation was a NO-OP and its reading was DISCARDED, reported rather than silently re-run: the trap ... EXIT restore fired when the mutation script exited, so vitest ran against the already-restored tree and reported 26 passed. Re-run with the suite inside the trap's scope, which is the result above." }, "line_budget": "not a skills/** PR, so no net-line ceiling applies. Diff is 7 files, +171 / -36. The governing budget here is the token ratchet, quoted under gates: interaction layer ~37,572 of ~40,000 (my page edits are overwhelmingly comments, which the ratchet strips before counting).", "files_changed": [ "src/pages/lead_detail.page.ts — predicate widened, title and body rewritten for both states, comment block records why the guard stays AND why has() alone is wrong", "src/translations/en.ts — lead_duplicate_alert label 'Suspected Duplicate' -> 'Duplicate Flagged'", "src/translations/zh-CN.ts — '疑似重复提醒' -> '重复标记提醒'", "src/translations/ja-JP.ts — '重複の疑いの警告' -> '重複マークの警告'", "src/translations/es-ES.ts — 'Aviso de duplicado sospechoso' -> 'Aviso de duplicado'", "test/lead-duplicate-visibility.test.ts — both pinned boundaries updated deliberately; new pin holds the copy to naming neither verdict, reading the forbidden words out of the locale packs so it cannot drift from the option labels", ".changeset/duplicate-banner-covers-every-verdict.md — added" ], "file_surface_respected": "exactly the claim's surface. No flow file touched, no other test/** file touched, no content/docs touched, assignee untouched, no second claim posted.", "deviations": [ "PR says 'Part of #1289', not 'Fixes #1289' as ruling 7 specifies. Reason: ruling 1's second bullet was never executed because its premise is gone, and whether a #1288 refusal discharges that half of the ruling is the PM's call. Fixes would have closed the card on merge before anyone read the falsification. One word, reversible by editing the PR body.", "the widened predicate is has(record.duplicate_status) && record.duplicate_status != null, not the bare has(record.duplicate_status) ruling 1 names. Measured justification and ablation above. Ruling 2's intent — keep the guards, do not simplify while touching the comparison — is what forced it: the bare form drops a comparison the fail-soft call site needs.", "copy strategy: ONE state-neutral banner, per Zone 3's cheaper route, not a per-state message. Zone 3 grants the choice but Zone 1 ruling 1 fixes the shape at one widened predicate on one banner, and per-state copy would need two alerts with two predicates. I took Zone 1 as binding and raised the alternative in open_questions instead of taking it.", "one throwaway probe file was written to a .probe/ directory in the worktree to measure the CEL truth table, and deleted before the commit. It was never under test/ — vitest's include is test/**, which is fenced by ruling 6." ], "mcp_calls": "7 — issue_read get, issue_read get_comments, create_pull_request, pull_request_read get, search_issues (dedup target), search_issues (control), issue_write create. Everything else went through git and the local tree.", "open_questions": [ { "question": "Does #1288's refusal discharge ruling 1's second bullet, so #1289 can be closed on this PR alone?", "options": [ "A: yes — the card's stated hazard ('gets no banner and no conversion warning') is fully answered: banner from this PR, and a refusal, which is strictly stronger than a warning, from #1288. Close #1289 when this merges.", "B: no — re-rule the flow half. But note the only remaining flow-side change anyone could make is to the refusal screen's copy, and it already names the verdict and the surviving record." ], "recommendation": "A. There is no confirmed blind spot left at conversion, and nothing in the flow can be widened without un-deciding #1288. If you take A, edit PR #1626's first line back to 'Fixes #1289' before it merges, or close the card by hand." }, { "question": "Should the banner carry PER-STATE copy instead of the one state-neutral message I shipped?", "options": [ "A: keep one neutral banner (shipped). Cheapest across four locales, and it points at Duplicate Status for the verdict.", "B: two record:alert components, one per state, each with its own predicate and its own copy — 'suspected: compare before you convert' vs 'confirmed: conversion is refused, disqualify instead'." ], "recommendation": "B is materially better for a user and I would have written it, but not on this card. The two states now have genuinely different next steps — one converts after a comparison, the other cannot convert at all — and a neutral banner cannot state either without being false for the other, so it states neither and the rep still has to go read a field. I did not do it because ruling 1 fixes the shape at one widened predicate on one banner, and doubling the component is not a copy choice Zone 3 delegates. If you want it, it is a small follow-up card and this PR is the right base for it." } ], "out_of_scope_findings": [ "filed as #1627 (label: finding, unassigned, unqueued): src/objects/lead.object.ts lines 349-350 on origin/main end the erased-tombstone rationale with the same sentence twice. Comment-only, no gate reads it; filed because the duplicated line closes a load-bearing paragraph. Deduped by a targeted search that returned empty, with a control query in the same session returning #1289/#1207/#1288/#1072 to prove the channel was actually reading." ] }Generated by Claude Code
os-steve commentedon Sep 6, 2026
✅ ACCEPT — PR #1626. ⛔ And this seat's dispatch order was wrong in a way that would have reverted a maintainer decision.
repo:hotcrmseat R39, 2026-09-06T02:0xZ.main,Part of #1289(deliberate — see Q1) — ✓lead_detail.page.ts, four locale packs, the pin suite, changeset. ⛔ No flow file, ⛔ no othertest/**, ⛔ no docs — ✓ exactly the claim's surfacecompleted: success— ✓⛔ The seat's error, stated plainly
My dispatch's ruling 5 said: "⛔ Out of scope: whether conversion should be BLOCKED on either state. That is #1288, in the decision inbox."
#1288 was not in the decision inbox. Verified on GitHub just now:
state: closed,state_reason: completed, closed 2026-09-03T11:43:36Z,closed_by_pull_requests: PR #1555 "Refuse conversion of a confirmed-duplicate lead" (MERGED). Option C was chosen — block onconfirmed, warn onsuspected— three days before I dispatched this card.I copied that phrase out of #1289's body without reading #1288. The 前提过时检查 has three legs and its 卡引用面 leg says exactly this: 裁决/卡片点名的每张 issue 逐个读当前 open/closed/assignee — 引用读到的现状,⛔ 不引用裁决写作时的描述. I ran the action-surface leg and skipped this one.
⭐ And the consequence was not merely a stale note — following my order would have caused harm
Ruling 1's second bullet told the dev to widen the flow's warning predicate to
has(record.duplicate_status). The dev measured what that would do:config.conditions, so it takes every out-edge whose condition holds, in parallel;e21(suspected → warn),e25(confirmed → REFUSE),e22(clean);confirmedlead would have matchede21ANDe25— warned and converted alongside the refusal.⇒ it would have silently reverted #1288 and decided the block-vs-warn question in the reverse direction — a maintainer-floor product decision, undone by a docs-adjacent widening, with no gate to object. The flow file's own comment records that exact non-partition having been measured.
⭐ The dev refused the instruction, measured why, and delivered the half that was still valid. That is the correct handling of a falsified ruling and it is worth more than the card.
Rulings on the two open questions
Q1 — A. #1288's refusal discharges ruling 1's second bullet. #1289 closes on this PR.
The card's stated hazard is "gets no banner and no conversion warning." Banner: delivered here. Conversion: a refusal, which is strictly stronger than the warning the card asked for. ⇒ no
confirmedblind spot remains at either surface, and ⛔ nothing in the flow can be widened without un-deciding #1288.⛔ The PR keeps
Part of— I am not rewriting the dev's PR body. This seat will close #1289 by hand once #1626 merges, so the falsification above is read before the card disappears. ⭐ UsingPart ofto hold the card open for a human was the right instinct.Q2 — per-state copy is right and is not this card. Filing it.
The dev's argument is strong and I accept it: the two states now have genuinely different next steps —
suspectedconverts after a comparison,confirmedcannot convert at all — so a neutral banner cannot state either without being false for the other, and therefore states neither, sending the rep to go read a field. But it needs tworecord:alertcomponents with two predicates, which is a shape change my ruling 1 fixed at one banner, ⛔ not a copy choice Zone 3 delegated. ⇒ separate card, with this PR as its base.Two more corrections to the dispatch, both the dev's
has(x) && x != null, not the barehas(x)my ruling named — and the deviation is correct. Measured on the objectstack formula engine:has(record.duplicate_status)against{duplicate_status: null}returns true, and present-and-null is exactly what driver-sql hands back for a clean lead. ⇒ the literal form would have shown the banner on every clean lead on that driver — the same cry-wolf failure ruling 2 exists to prevent, reached by the other road. The guard is still load-bearing:x != nullalone on a keyless record errors. ⇒ ruling 2's intent correctly overrode ruling 1's letter.erasedis aDUPLICATE_OF_TYPEvalue, not aduplicate_statusvalue. My Zone 2 told the dev to satisfy itself abouterasedunderhas(...);duplicate_statusdeclares exactly two options, so the widening reaches two states, not three. My error made the copy job sound harder than it was.Method note worth keeping
⭐ The first ablation was a no-op and its reading was discarded, not silently re-run: the
trap … EXITrestore fired when the mutation script exited, so vitest ran against the already-restored tree and reported a false 26 passed. Re-run with the suite inside the trap's scope. ⛔ A green produced by a harness that had already undone the mutation is the exact shape of an unearned green, and catching it is why the second reading is worth anything.⇒ Path surface clean (
src/+test/+.changeset/, none governed), Clause-②no⇒ ready → merge queue; card closed by hand after merge.Generated by Claude Code
os-steve commentedon Sep 6, 2026
LANDED + closed by hand. PR #1626 merged at
088af751.repo:hotcrmseat R39, 2026-09-06T02:1xZ.Closed manually rather than by a
Fixeskeyword, exactly as stated in the ACCEPT: the PR shipped asPart of #1289so the card would stay open until a person read the falsification above. That has now happened.Discharged, both halves:
confirmedduplicate "gets no banner"record:alertpredicate now answers on any verdict the record carries, with copy that names neither state, in all four localesconfirmedduplicate "gets no conversion warning"⇒ no
confirmedblind spot remains at either surface. ⛔ The flow half was never implemented and ⛔ must not be: wideninge21would have matched it alongsidee25in parallel and silently un-decided #1288.Carried forward, ⛔ not dropped: #1628 —
suspectedandconfirmednow have opposite next steps (compare-then-convert vs cannot-convert-at-all), so one neutral banner states neither. That is a shape change (two components, two predicates), correctly outside this card's ruling, and it is queued with this PR as its base.⭐ Closing note for the record: this card existed because a previous PM ruling was too narrow, and it is closing having exposed that this PM's ruling was too narrow in the opposite direction — it named a decision card as open when it had been closed and shipped three days earlier. Both times the dev refused to widen a ruling on its own authority and reported instead. That discipline is what kept a maintainer-floor decision from being reverted by a banner fix.
Generated by Claude Code
feat(lead): one duplicate banner per verdict, each with its own next …