Dedupe duplicate ids and heal them silently
The crash-class gap the integrity design pass found (DESIGN/01 - Fractal layout rules; 02 - Live-reload resilience): the loader had no board-wide dedupe at all, so two hand-copied folders sharing a UUID put two equal ItemIDs into one snapshot - which SwiftUI's ForEach does not tolerate. Built to the day's re-rulings, both landing mid-flight: the user-gated Repair banner retired (176c852- the heal runs silently) and the container boundary became the first tie-break (f153e79- the visible card never loses to its own trash ghost). IntegrityRules.dedupe (pure, occurrence list in, verdict out): group by canonical identity, collapse case-spelled twins first - spellings with a live occurrence outrank trash-only spellings, then canonical lowercase, then lexicographically first; losers are silent strays (LoadWarning.caseTwinIgnored - spelling artifacts, never reminted) - then earlier-occurrence-wins across the surviving spelling's folders on a four-rung ladder: live-before-trashed, git path history rank, FS birth date (nil is no comparison, never .distantPast), traversal order. Occurrences are exactly the identity-bearing folders: lanes, cards, .trash entries - a UUID-shaped folder under a card is content. BoardLoader walks lanes as WalkedLane and builds Lane values only on the far side of the verdict, so a withheld card can never reach a snapshot; a name-only gate keeps the healthy-board cost at one dictionary pass, no disk reads. Withheld subtrees are still walked - a hand-copied lane's nested cards are their own withheld occurrences, reminted at the finest grain like the import boundary would have. A withheld trash entry's trashKinds reading leaves with it. The git rung is a seam (BoardLoader.IdentityHistoryRanker, one closure keyed by root-relative path) because base links no git machinery - base injects nothing and falls through; pro-m1 owns the ranker (card annotated). The heal: Defect.duplicateIdentity (signature duplicate:<path>:<id>) rides HealScheduler as the fourth scheduled heal, ordered last among the content heals because a remint renames folders and would stale the paths the same load handed the relocation and migration. BoardWriter.remintDuplicateIdentity re-verifies twice at write time - the folder still carries the losing identity AND something else still does (the vanished-duplicate race no-ops from either side) - then renames to a fresh v4 minted against the whole board's identity bag. A rename and nothing else: no index.md opened, no modified stamp, no modified-by clear; the receipt is heal-marked (pro-m1's committer splits it out, named by 06's kept Repair verb); no undo step - heals are not gestures. The notice is the design's own sentence ("Repaired duplicate id - 'Fix login'"; several fold to a count), a loss row on the relocation's reasoning; WriteOperation.repairDuplicateID carries the failure mirror. Fixture repair rode along: duplicate-order-tie-break.kanban had a lane and its own card sharing a UUID - a genuine duplicate the new pass correctly withholds; the folder rename landed infeae6d0, the matching test constant lands here. 66 tests added (DuplicateIdentityTests: the ladder rung by rung, the straddles, withheld-lane subtrees, remint idempotence and races, the one-heal-cycle window, all phrasing). 1804 green on both schemes. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
@@ -162,6 +162,14 @@ public final class HealScheduler {
|
||||
case migratedTombstones(cards: [String?], lanes: [String?])
|
||||
/// Loss row, warning tone: a claimed name's squatter was moved aside, named old and new.
|
||||
case displacedClaimedNames([BannerCenter.Displacement])
|
||||
/// Loss row, warning tone: withheld duplicate ids were reminted, named by title.
|
||||
///
|
||||
/// **Announced rather than silent, and that is the ruling's own word** (01-storage-format.md
|
||||
/// § Validation and healing, re-ruled 2026-07-29): a heal that changes user content — and an
|
||||
/// identity is content — "announces itself with a warning-tone notice … never a consent gate".
|
||||
/// This row is what replaced the retired user-gated Repair banner: the same information, none
|
||||
/// of the waiting.
|
||||
case remintedDuplicateIDs(titles: [String?])
|
||||
}
|
||||
|
||||
private static func post(_ notice: HealNotice, to banners: BannerCenter) {
|
||||
@@ -174,6 +182,8 @@ public final class HealScheduler {
|
||||
banners.postMigratedTombstones(cards: cards, lanes: lanes)
|
||||
case let .displacedClaimedNames(displacements):
|
||||
banners.postDisplacedClaimedNames(displacements)
|
||||
case let .remintedDuplicateIDs(titles):
|
||||
banners.postRemintedDuplicateIDs(titles)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user