Skip to content

No affordance for case-variant duplicate accounts created outside lead conversion #655

Description

@os-zhuang

Restart-when: the "fresh installs only" deployment premise is formally dropped, OR a real duplicate-account incident is reported against a shipped org.
Restart-touch: src/objects/account.object.ts docs/MAINTENANCE.md

Found while implementing #626 (PR #654). Filed unassigned, out of scope there.

What #626 fixed, and what it left

#626 makes lead conversion match accounts on a normalized company name, so the conversion path no longer creates near-duplicates (Acme Corp / ACME Corp). It deliberately did not add a unique index on crm_account.name_normalized — the reasoning is in src/objects/account.object.ts, but the short version is that account-name uniqueness already lives per-tenant on name (#625), and a unique normalized column would subsume that constraint and hard-fail writes that succeed today.

So every non-conversion path can still create two accounts whose names differ only in case or spacing: the Console form, the record API, a CSV import, a future seed row. Nothing detects it and nothing merges it.

Two concrete consequences

  1. No detection. A near-duplicate pair sits in the account list indefinitely. Contrast crm_lead, which already has the soft shape for the same class of problem: lead_duplicate_check flags a re-captured email as duplicate_status: 'suspected' and the suspected_duplicates view is the review queue. crm_account has neither half.
  2. Non-deterministic reuse. When two accounts do share a normalized name, lead_conversion's find_account node reuses an arbitrary one — the built-in get_record executor accepts filter / fields / limit and has no sort option (measured on 17.0.0-rc.1), so there is no "oldest wins" to express. Reusing one of N is still better than creating the N+1th, which is what happened before Normalize account matching in lead conversion (split out of #598 scope 2) #626, but it is not a defined outcome.

Possible shapes (not a recommendation)

Worth knowing before picking

The reason #626 could defer this at all is the maintainer's statement that this repo's deployment shape is fresh installs only. That premise is what bounds the create_index hazard and what makes "flow-level dedupe is enough" defensible today. If it ever stops holding, re-read this issue together with the index decision in account.object.ts and docs/MAINTENANCE.md §3.3 — both were written as conditional, not universal.

Activity

  1. added
    enhancementNew feature or request
    metadataDeclarative metadata — schema, security posture, UI surfaces
    pm:queueReady for the PM dispatch loop
    on Aug 2, 2026
  2. added
    needs-user-decisionNeeds the maintainer's call before work proceeds
    and removed
    pm:queueReady for the PM dispatch loop
    on Aug 5, 2026
  3. yinlianghui commented on Aug 5, 2026

    @yinlianghui
    Collaborator

    分诊(PM 循环,17.0 发布修复线,会话 session_01VHrPAGEgFDoHjphqYG4BMa):摘 pm:queue,改挂 needs-user-decision。

    理由:本单是能力扩张类提案(为 lead conversion 之外创建的重复客户提供治理面),不属于 17.0 发布验收的 bug 收敛范围。按维护者本轮指示,enhancement 提案(#594–#601、#639、#655 等)统一进决策收件箱,由维护者按创业阶段聚焦原则拍板是否立项;拍板前不派发。这不是对提案价值的否定,是排期归属的判断。


    Generated by Claude Code

  4. added and removed
    needs-user-decisionNeeds the maintainer's call before work proceeds
    on Aug 11, 2026
  5. huangyiirene commented on Aug 11, 2026

    @huangyiirene
    Collaborator

    Ruling (maintainer, 2026-08-11, PM chat, verbatim: 「接受你的全部建议」): reclassified finding — held, not queued. Dedupe affordances for case-variant duplicate accounts have no real pull in a reference app today; the unique-index route was measured to fail on stock that already holds both spellings; the merge action is the only shape that treats existing stock and it is L-size. The record stays inside the state machine as a finding. Revisit trigger: the next real duplicate-account incident (or the "fresh installs only" premise being formally dropped) — at that point the soft-flag hook (lead-mirror precedent) is the entry-level shape to price first.


    Generated by Claude Code

  6. huangyiirene commented on Aug 31, 2026

    @huangyiirene
    Collaborator

    Ledger exit — executing the recorded 2026-08-11 maintainer ruling (verbatim: 「接受你的全部建议」— "reclassified finding — held, not queued"), whose label transition was never performed: needs-user-decision removed, finding applied. Class-1 execution under the recorded-ruling channel (director seat, summon #7, session session_01KGtaLpkW1mycWgkbSb3H6t): the ruling mechanically determines the state, the flip is one revert away, no manual-floor item moves. The revisit trigger stays as recorded on the ruling comment: the next real duplicate-account incident, or the "fresh installs only" premise being formally dropped.


    Generated by Claude Code

  7. added and removed
    needs-user-decisionNeeds the maintainer's call before work proceeds
    on Aug 31, 2026
  8. hotlong commented on Sep 2, 2026

    @hotlong
    Contributor

    Grading → pm:on-hold, finding retired. ⚠️ The disposition is UNCHANGED; only the label encoding is corrected. R18, repo:hotcrm seat.

    What I am not doing

    ⛔ I am not re-opening or narrowing the maintainer's ruling of 2026-08-11 (verbatim: 「接受你的全部建议」), which reads:

    reclassified finding — held, not queued. … The record stays inside the state machine as a finding. Revisit trigger: the next real duplicate-account incident (or the "fresh installs only" premise being formally dropped) — at that point the soft-flag hook (lead-mirror precedent) is the entry-level shape to price first.

    The answer stays "not now", the revisit trigger stays exactly as recorded, and the entry-level shape stays the soft-flag hook. Nothing about the decision moves.

    What is wrong, and why it is worth a write

    finding in this state machine means awaiting first-touch grading — the count of bare finding labels is the ungraded-backlog metric. This card has been graded, twice, and ruled on by the maintainer. Encoding "decided: not now" as "nobody has looked at this" has a measurable cost, and I paid it this round: #655 came up in R18's sweep as an ungraded finding and I read the whole thread to discover it had been decided three weeks ago. Every future seat pays the same toll, and the health metric overstates the ungraded backlog by one every round.

    The state machine already has the word for "decision made, answer is not now": pm:on-hold.

    ⇒ ⭐ This is not a disagreement with the ruling — it is the ruling, written in the label that means what the ruling said. "Stays inside the state machine" is better served by pm:on-hold, which is a state with a defined exit, than by a label that means the opposite of what happened here.

    The exit, made machine-readable

    A pm:on-hold card is only legal with an exit something can actually fire. The recorded trigger was prose, so here it is in the greppable form:

    Restart-when: the "fresh installs only" deployment premise is formally dropped,
                  OR a real duplicate-account incident is reported against a shipped org.
    Restart-touch: src/objects/account.object.ts
                   docs/MAINTENANCE.md
    

    ⭐ Restart-touch is what turns this from an un-exitable hold into a real one. Both named files are where the "fresh installs only" premise physically lives — account.object.ts carries the reasoning for declining the unique index, and MAINTENANCE.md §3.3 carries the deployment-shape statement. Both were written as conditional, not universal, and the card says so. ⇒ Any future dispatch whose file surface touches either one intersects this hold and re-reads this card, which is exactly when the premise would be changing.

    ⚠️ Note for whoever fires it: the create_index hazard is measured and still binding — a unique index on name_normalized fails on any deployment already holding both spellings. That is precisely why the premise is load-bearing, and why dropping it changes which of the card's three shapes is even available.

    ⛔ Not closed, and that is deliberate

    The standing guidance is that a card nobody will develop should be closed rather than held, because a hold with no firing mechanism is a state nobody can leave. That guidance does not apply here: this hold now has a firing mechanism, and the maintainer explicitly asked for the record to stay in the state machine. ⇒ Held, with an exit. If the maintainer prefers it closed, one word and it closes — the record survives either way.


    Generated by Claude Code

  9. added theissue type on Sep 2, 2026
  10. added and removed on Sep 9, 2026
  11. objectstack-fleet commented on Sep 24, 2026

    @objectstack-fleet
    Contributor

    repo:hotcrm seat, session_01X8U3asekbiC7yWoEPWR4Dg · stock re-triage group 1 (maintainer-confirmed ten-card group; maintainer reply verbatim: 「批1 同意」) · 2026-09-24T23:11Z

    Closed not_planned — the 2026-08-11 disposition ("not now") is unchanged; only the form changes

    The hold's exit ("fresh-installs-only premise formally dropped, OR a real duplicate-account incident") has no machine that can fire it, and the rules say a card nothing can wake is not held but closed not_planned. Reopening is free. The soft-flag hook (the lead-mirror precedent) stays the entry-level shape to price first if it is reopened.


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or requestmetadataDeclarative metadata — schema, security posture, UI surfacespriority:p2Medium: important, M3

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions