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 in feae6d0, 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:
2026-07-29 18:19:52 -04:00
parent 68fa503250
commit 0463540aea
8 changed files with 1979 additions and 24 deletions
+299
View File
@@ -450,6 +450,9 @@ public enum IntegrityRules: Sendable {
case legacyTombstone(LegacyTombstone)
/// A claimed board-root name held by the wrong kind of node (ruled 2026-07-29).
case claimedNameSquatted(ClaimedNameSquatter)
/// A later occurrence of an identity the board already carries withheld from the snapshot
/// and reminted by the scheduled heal (re-ruled 2026-07-29 the silent remint).
case duplicateIdentity(DuplicateIdentity)
/// The scheduled-heal classes, which are also the engine's memo keys and its
/// banner-posture rows (`HealScheduler`).
@@ -463,6 +466,7 @@ public enum IntegrityRules: Sendable {
case looseCardFiles
case legacyTombstone
case claimedNameSquatted
case duplicateIdentity
case staleAgentGuide
}
@@ -471,6 +475,7 @@ public enum IntegrityRules: Sendable {
case .looseCardFiles: .looseCardFiles
case .legacyTombstone: .legacyTombstone
case .claimedNameSquatted: .claimedNameSquatted
case .duplicateIdentity: .duplicateIdentity
}
}
@@ -492,9 +497,243 @@ public enum IntegrityRules: Sendable {
// of squatter is a new defect, and a heal that failed on one has no claim to have
// failed on the other.
["claimed:\(work.name):\(work.found.rawValue)"]
case let .duplicateIdentity(work):
// The *identity* is part of the picture beside the path: the same folder losing a
// different collision (its winner reminted, a third copy landing) is new work, and a
// heal that failed on one has no claim to have failed on the other.
["duplicate:\(work.path):\(work.identity)"]
}
}
}
// MARK: - The board-wide identity dedupe
/// One identity-bearing folder the walk met the **input** to `dedupe(_:)`, and everything the
/// rule needs about an occurrence (01-storage-format.md § Fractal layout Rules, "Duplicate ids
/// within a board are never tolerated").
///
/// Occurrences are exactly the schema's identity-bearing folders: depth-1 lanes, depth-2 cards,
/// and `.trash/`'s flat entries. Nothing deeper is one "level is position", and a UUID-shaped
/// folder under a card is content, not an identity.
public struct IdentityOccurrence: Sendable, Equatable {
/// **Which side of the container boundary an occurrence sits on** the board or `.trash/`
/// (02-architecture.md Live-reload resilience already makes "container side" vocabulary:
/// re-resolution matches UUID *and* container side).
///
/// It is a field rather than something derived from `path` here because `IntegrityRules` takes
/// the facts it needs as parameters rather than parsing paths or reading disk the loader
/// knows which container it walked, and telling the rule beats re-deriving it from a string.
public enum Container: Sendable, Equatable {
/// A lane, or a card under a lane something the board renders.
case live
/// A flat `.trash/` entry a card or a trashed lane.
case trashed
}
/// The folder's path **relative to the board root** `"<lane>"`, `"<lane>/<card>"`,
/// `".trash/<card>"`.
///
/// A path rather than an `ItemID` pair, and that is forced rather than chosen: the whole
/// subject here is *two folders carrying one id*, so an id-keyed payload would be ambiguous
/// about which of them it names. The path is the only unambiguous key a duplicate has, and it
/// stays relative for `LooseCardFiles`' reason the write joins it onto the store's
/// *current* root, so a board renamed mid-session heals at its new location.
public let path: String
/// The folder name exactly as it is spelled on disk. Case matters here and only here: the
/// case-twin collapse compares spellings, everything else compares identities.
public let name: String
/// Which container this occurrence was walked in **the first tie-break**, ahead of history
/// and age alike (01-storage-format.md § Fractal layout Rules, stated 2026-07-29).
public let container: Container
/// The item's title as written, `nil` for an untitled one "Untitled" is a rendering, never
/// a value (03-board-ui.md § Card face), so the phrasing layer decides what to call it.
public let title: String?
/// The filesystem birth date (`.creationDateKey`), `nil` when it cannot be read the
/// **second** rung of the earlier-occurrence-wins ladder.
public let birth: Date?
/// Where the git path history places this path, `nil` when the board has no history or the
/// path is untracked the **first** rung, injected through `BoardLoader.IdentityHistoryRanker`.
public let historyRank: Int?
public init(
path: String,
name: String,
container: Container,
title: String?,
birth: Date?,
historyRank: Int?
) {
self.path = path
self.name = name
self.container = container
self.title = title
self.birth = birth
self.historyRank = historyRank
}
/// The occurrence's identity its name reduced to a UUID *value*.
public var identity: String { canonicalIdentity(name) }
}
/// What the dedupe decided: the two classes of loser, each in traversal order.
///
/// **The two are deliberately different verdicts**, not one list with a flag: a case twin is
/// *tolerated* (a spelling artifact of the same item logged, preserved, never rendered, never
/// touched) and a content duplicate is *healed* (a copy the app remints). Collapsing them would
/// mean either announcing spelling or reminting content the user never made.
public struct DedupeVerdict: Sendable, Equatable {
/// The later occurrences withheld from the snapshot the heal's work.
public let duplicates: [DuplicateIdentity]
/// The case-spelled twins skipped silently the tolerate tier's work, which is none.
public let caseTwins: [CaseTwin]
}
/// **The board-wide dedupe** one occurrence per identity, decided from an occurrence list and
/// nothing else (01-storage-format.md § Fractal layout Rules, settled; the silent-remint
/// re-ruling of 2026-07-29 changed what happens *after* this, never what it decides).
///
/// A snapshot must never carry two items with equal ids SwiftUI's `ForEach` does not tolerate
/// it so this runs on every load and its answer is subtractive: every group of occurrences
/// sharing one identity keeps exactly one, and every other member is named here.
///
/// ### The two classes, in the order they apply
///
/// 1. **Case-spelled twins collapse first, and silently.** Occurrences of one identity whose
/// name *strings* differ can only differ in case (they are the same hex under
/// `canonicalIdentity`), which makes them spelling artifacts of one item rather than copies:
/// one spelling wins and every other takes the **stray** posture skipped with a pointed log
/// line, preserved verbatim, never rendered, never reminted. Reminting one would *create*
/// duplicate content the user never made.
///
/// **The winning spelling is chosen under the container preference too** (stated 2026-07-29):
/// spellings carried by at least one *live* occurrence are the candidates, and only among those
/// or among all of them when the whole group is trashed does canonical-all-lowercase-else-
/// lexicographically-first decide. Without that filter a live card spelled `AAAA` would lose
/// the spelling contest to its own lowercase ghost in the trash and be *skipped*, which is the
/// straddle case reading the rule backwards: the visible card never loses to its own ghost.
/// 2. **Then earlier-occurrence-wins across what is left**, which all share one spelling and so
/// necessarily sit under different parents a hand copy keeping its UUID. The earliest
/// occurrence renders; every later one is **withheld** and healed.
///
/// A consequence worth naming: an occurrence that is *both* a hand copy whose case was also
/// hand-changed degrades to the silent case-twin posture and is never reminted. That is the
/// spelling-artifacts-stay-silent ruling read literally, and the conservative direction: the
/// board renders one item per id either way, and the app declines to mint identity for a folder
/// whose spelling says "the same item, typed differently".
///
/// ### The ladder
///
/// 0. **The container boundary** (`container`) **the first tie-break, ahead of history and age
/// alike** (01-storage-format.md § Fractal layout Rules, stated 2026-07-29): "when occurrences
/// straddle live and trashed, the **live occurrence keeps the identity** regardless of age".
/// The realistic straddle is a restore done as a *copy* an -drag out of the trash in Finder,
/// an agent that copies instead of moves where the ghost left behind is genuinely the older
/// folder and often the tracked one, so every other rung would withhold the very card the user
/// just restored and render its ghost instead. The heal remints the trashed occurrence. The same
/// preference governs a trashed lane sharing a live lane's UUID.
/// 1. **Git path history** (`historyRank`): both tracked, the path that entered history first
/// wins; one tracked, it outranks the newcomer outright.
/// 2. **Filesystem birth date** (`birth`): the older folder wins. Only consulted when *both*
/// dates are readable and they differ one unreadable date is no comparison at all.
/// 3. **Deterministic traversal order**, which is `occurrences`' own order and therefore the
/// caller's contract: lane `order`, then card `order`, then the folder-name tie-break
/// (`BoardLoader` passes them exactly so).
///
/// Rungs 13 are the *earlier-occurrence-wins* rule; rung 0 is not about age at all, which is why
/// it sits outside and above it.
///
/// Pure, like everything here: the container, the birth dates and the history ranks are read by
/// the loader and arrive as values, so the whole rule is pinned by the suite without a filesystem
/// or a repo in the way.
public static func dedupe(_ occurrences: [IdentityOccurrence]) -> DedupeVerdict {
// Grouped by identity, first-seen order preserved determinism starts here, because a
// dictionary's own iteration order is not one.
var members: [String: [Int]] = [:]
var identities: [String] = []
for (index, occurrence) in occurrences.enumerated() {
let identity = occurrence.identity
if members[identity] == nil { identities.append(identity) }
members[identity, default: []].append(index)
}
var duplicates: [(index: Int, work: DuplicateIdentity)] = []
var caseTwins: [(index: Int, work: CaseTwin)] = []
for identity in identities {
guard let group = members[identity], group.count > 1 else { continue }
// 1. The winning *spelling*, under the container preference first: a spelling some live
// occurrence carries outranks one only trash ghosts carry, and the canonical-else-
// lexicographic rule then decides among the candidates. A wholly trashed group has no
// live candidates and falls through to all of them, unchanged. `identity` is the
// all-lowercase form by construction, so "is the canonical spelling present" is one
// membership test either way.
let spellings = Set(group.map { occurrences[$0].name })
let liveSpellings = Set(
group.lazy.filter { occurrences[$0].container == .live }.map { occurrences[$0].name }
)
let candidates = liveSpellings.isEmpty ? spellings : liveSpellings
let canonical = candidates.contains(identity) ? identity : candidates.sorted()[0]
// 2. Earlier-occurrence-wins among the canonical spelling's occurrences. `sorted` is not
// guaranteed stable, so the traversal index is the comparator's own last rung rather
// than something left to the sort.
let contenders = group.filter { occurrences[$0].name == canonical }
let ranked = contenders.sorted { entered(occurrences[$0], at: $0, before: occurrences[$1], at: $1) }
let winner = occurrences[ranked[0]].path
for index in group where occurrences[index].name != canonical {
caseTwins.append((index, CaseTwin(path: occurrences[index].path, winner: winner)))
}
for index in ranked.dropFirst() {
duplicates.append((index, DuplicateIdentity(
path: occurrences[index].path,
identity: identity,
title: occurrences[index].title,
winner: winner
)))
}
}
// Traversal order across groups too: the notice's subjects and the log's lines read in the
// order the board is laid out, not in the order a dictionary happened to hand out identities.
return DedupeVerdict(
duplicates: duplicates.sorted { $0.index < $1.index }.map(\.work),
caseTwins: caseTwins.sorted { $0.index < $1.index }.map(\.work)
)
}
/// The precedence comparator the four-rung ladder above, and the whole of the winner rule.
///
/// Named for its majority (`entered before ` is earlier-occurrence-wins' own phrasing) even
/// though rung 0 is not about entry order at all: the container preference is stated as *the first
/// tie-break*, so it belongs in the one comparator rather than as a pre-partition the callers of
/// this rule would each have to remember.
private static func entered(
_ lhs: IdentityOccurrence,
at lhsIndex: Int,
before rhs: IdentityOccurrence,
at rhsIndex: Int
) -> Bool {
// 0. The container boundary, ahead of everything: the visible card never loses to its own
// ghost, however much older or better-tracked the ghost is.
if lhs.container != rhs.container { return lhs.container == .live }
switch (lhs.historyRank, rhs.historyRank) {
case let (left?, right?):
// Both tracked: the path that entered history first.
if left != right { return left < right }
case (.some, .none):
// "The path history already tracks outranks the newcomer" read literally.
return true
case (.none, .some):
return false
case (.none, .none):
break
}
if let left = lhs.birth, let right = rhs.birth, left != right { return left < right }
return lhsIndex < rhsIndex
}
}
// MARK: - The defect payloads
@@ -580,3 +819,63 @@ public struct ClaimedNameSquatter: Sendable, Equatable {
self.expected = expected
}
}
/// A **later occurrence** of an identity the board already carries a folder hand-copied in Finder
/// keeping its UUID (01-storage-format.md § Fractal layout Rules: "Duplicate ids within a board are
/// never tolerated Every later occurrence is withheld from rendering preserved verbatim, pointed
/// log line").
///
/// **Withheld, then reminted.** The loader keeps it out of every snapshot, which is what makes the
/// one-item-per-id invariant hold by construction SwiftUI's `ForEach` does not tolerate two equal
/// ids and the scheduled heal then gives it the fresh identity the import boundary would have
/// minted, after which it renders as an ordinary item (re-ruled 2026-07-29: a silent heal, superseding
/// the former user-gated Repair banner "Lanework owns the board and re-mints object UUIDs at will").
///
/// Nothing on disk is lost in the meantime: the folder, its `index.md`, its children and its strays
/// are exactly where they were, and the withheld window is one heal cycle rather than a standing
/// condition (02-architecture.md Live-reload resilience).
public struct DuplicateIdentity: Sendable, Equatable {
/// The withheld folder's path relative to the board root see `IdentityOccurrence.path` for why
/// a duplicate is keyed by path and not by id.
public let path: String
/// The identity both occurrences share, canonically (lowercased). Part of the heal's signature:
/// the same folder losing a *different* collision is new work.
public let identity: String
/// The withheld item's title as written, `nil` for an untitled one what the notice names.
public let title: String?
/// The path of the occurrence that won the log line's other half, and the answer to the only
/// question the log line owes ("withheld in favour of *what*").
public let winner: String
public init(path: String, identity: String, title: String?, winner: String) {
self.path = path
self.identity = identity
self.title = title
self.winner = winner
}
}
/// A folder whose name is a **case-spelled twin** of another occurrence of the same identity one
/// item typed two ways, not two items (01-storage-format.md § Fractal layout Rules: "the canonical
/// all-lowercase spelling wins where present, else the lexicographically first spelling; the loser
/// takes the stray posture skipped with a pointed log line, preserved verbatim, never rendered").
///
/// The winning *spelling* is picked under the container preference first (stated 2026-07-29 see
/// `IntegrityRules.dedupe(_:)`), so a live card is never skipped in favour of its own trashed ghost's
/// spelling. Which side wins is all that changed: a twin is still silent either way.
///
/// **Not a `Defect`, and that is the ruling rather than an omission**: this is the *tolerate* tier
/// there is nothing to do. The twin is a spelling artifact of the item that rendered, so reminting it
/// would create duplicate content the user never made, and announcing it would surface spelling as a
/// problem. It reaches the caller as a `LoadWarning`, where every other tolerated stray lives.
public struct CaseTwin: Sendable, Equatable {
/// The skipped folder's path relative to the board root.
public let path: String
/// The path of the occurrence whose spelling won what the log line names it a twin *of*.
public let winner: String
public init(path: String, winner: String) {
self.path = path
self.winner = winner
}
}