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:
@@ -672,11 +672,21 @@ public enum BoardWriter: Sendable {
|
||||
/// `55555555-…` and a resident `55555555-…` spelled uppercase are **one** identity, and a
|
||||
/// verbatim set would miss exactly that collision and let a duplicate UUID into the board.
|
||||
private static func identities(inBoard boardRoot: URL) -> Set<String> {
|
||||
var identities: Set<String> = []
|
||||
Set(identityOccurrences(inBoard: boardRoot))
|
||||
}
|
||||
|
||||
/// The same walk as a **bag rather than a set** — every identity-bearing folder's canonical name,
|
||||
/// duplicates included, which is what lets the duplicate-id remint ask "does anything else still
|
||||
/// carry this identity" instead of merely "is it present" (`remintDuplicateIdentity`).
|
||||
///
|
||||
/// The two exist as one walk deliberately: a re-verify that read the board differently from the
|
||||
/// collision probe would be a second definition of "what this board contains".
|
||||
private static func identityOccurrences(inBoard boardRoot: URL) -> [String] {
|
||||
var identities: [String] = []
|
||||
for lane in childCandidates(of: boardRoot) {
|
||||
identities.insert(IntegrityRules.canonicalIdentity(lane.lastPathComponent))
|
||||
identities.append(IntegrityRules.canonicalIdentity(lane.lastPathComponent))
|
||||
for card in childCandidates(of: lane) {
|
||||
identities.insert(IntegrityRules.canonicalIdentity(card.lastPathComponent))
|
||||
identities.append(IntegrityRules.canonicalIdentity(card.lastPathComponent))
|
||||
}
|
||||
}
|
||||
// **The trash counts.** Board-wide uniqueness spans both containers (01-storage-format.md
|
||||
@@ -687,7 +697,7 @@ public enum BoardWriter: Sendable {
|
||||
// This is also what makes `deleteCardToTrash`'s "collision is impossible" true rather than
|
||||
// hopeful: an import that would have produced the twin was reminted before it landed.
|
||||
for card in childCandidates(of: trashFolder(inBoard: boardRoot)) {
|
||||
identities.insert(IntegrityRules.canonicalIdentity(card.lastPathComponent))
|
||||
identities.append(IntegrityRules.canonicalIdentity(card.lastPathComponent))
|
||||
}
|
||||
return identities
|
||||
}
|
||||
@@ -2198,6 +2208,71 @@ public enum BoardWriter: Sendable {
|
||||
return freed
|
||||
}
|
||||
|
||||
/// **Remints a withheld duplicate identity** — the write half of the duplicate-id heal
|
||||
/// (01-storage-format.md § Fractal layout ▸ Rules, re-ruled 2026-07-29: "the heal gives each
|
||||
/// withheld occurrence the fresh identity the import boundary would have minted").
|
||||
///
|
||||
/// Copy semantics applied at detection: a hand copy in Finder *was* a copy, so it gets what a
|
||||
/// copy gets — a fresh lowercase v4 folder name, minted away from every identity in the board.
|
||||
/// After it lands, the folder renders as an ordinary item.
|
||||
///
|
||||
/// **A rename and nothing else.** The folder keeps its parent; its `index.md`, its frontmatter,
|
||||
/// its children, its attachments and its strays are never opened. That is not a carve-out but the
|
||||
/// existing write discipline answering: a heal that only renames stamps nothing — no `modified`,
|
||||
/// no cleared `modified-by` — because this is an identity repair, not an edit (§ Validation and
|
||||
/// healing).
|
||||
///
|
||||
/// **It re-verifies against disk, twice over** (§ Validation and healing: "every scheduled heal
|
||||
/// re-verifies its defect against disk at write time and no-ops when it is gone"), and each check
|
||||
/// answers `nil` — success, never an error:
|
||||
///
|
||||
/// 1. The folder is still there, still a directory, and still spelled with the identity the
|
||||
/// detection named. A folder already reminted (this heal running twice, another device's heal
|
||||
/// arriving first) fails here.
|
||||
/// 2. **Something else still carries that identity.** The winner may have been hand-deleted or
|
||||
/// moved out since the load, in which case this folder is no longer a duplicate of anything and
|
||||
/// reminting it would change an identity for no reason at all — the vanished-duplicate race,
|
||||
/// read from the surviving side.
|
||||
///
|
||||
/// **Heal-marked**, because the app started it on its own: the receipt is what splits the remint
|
||||
/// into its own commit on git boards, named for the Repair verb (06-history-undo.md ▸ Commit
|
||||
/// messages). Undo never sees it — heals are not gestures (13-native-undo.md).
|
||||
///
|
||||
/// - Parameter duplicate: the withheld occurrence, `path` relative to `boardRoot` so the write
|
||||
/// lands wherever the board lives *now*.
|
||||
/// - Returns: the fresh identity, or `nil` when the defect was already gone.
|
||||
@discardableResult
|
||||
public static func remintDuplicateIdentity(
|
||||
_ duplicate: DuplicateIdentity,
|
||||
inBoard boardRoot: URL
|
||||
) throws(BoardWriteError) -> ItemID? {
|
||||
let operation = WriteOperation.repairDuplicateID(title: duplicate.title)
|
||||
let folder = boardRoot.appendingPathComponent(duplicate.path, isDirectory: true)
|
||||
|
||||
// 1. Still there, still a folder, still carrying the identity that lost.
|
||||
guard IntegrityRules.node(at: folder) == .directory,
|
||||
IntegrityRules.canonicalIdentity(folder.lastPathComponent) == duplicate.identity
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
// 2. Still a duplicate *of something*. One occurrence is this folder itself, so the identity
|
||||
// has to appear at least twice for the defect to still stand.
|
||||
let occurrences = identityOccurrences(inBoard: boardRoot)
|
||||
guard occurrences.filter({ $0 == duplicate.identity }).count > 1 else { return nil }
|
||||
|
||||
// Minted away from every identity in the board, not merely from this parent's children: the
|
||||
// point of the remint is board-wide uniqueness, and a fresh name colliding with a folder two
|
||||
// lanes over would trade one duplicate for another.
|
||||
let fresh = freshUUIDName(in: folder.deletingLastPathComponent(), avoiding: Set(occurrences))
|
||||
try renameFolder(folder, toSiblingNamed: fresh, operation: operation)
|
||||
// The move pair is `renameFolder`'s; the heal mark is this call's, because the remint is
|
||||
// app-initiated work whose paths commit separately (06-history-undo.md ▸ Commit messages).
|
||||
EchoLedger.current?.markHeal(
|
||||
at: folder.deletingLastPathComponent().appendingPathComponent(fresh, isDirectory: true)
|
||||
)
|
||||
return ItemID(rawValue: fresh)
|
||||
}
|
||||
|
||||
/// The card's flat attachment listing (01-storage-format.md § Attachments, "the app's
|
||||
/// attachment surfaces … are flat: top-level files only"): the top-level *files* of
|
||||
/// `attachments/`, subfolders and hidden files excluded, symlinks excluded, sorted
|
||||
@@ -2588,6 +2663,20 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible {
|
||||
/// owns that file end to end and has one outcome the user could care about.
|
||||
case displaceClaimedName(name: String)
|
||||
|
||||
/// A withheld duplicate id being reminted — the duplicate-id heal's write (01-storage-format.md
|
||||
/// § Fractal layout ▸ Rules, re-ruled 2026-07-29 from its former banner gate).
|
||||
///
|
||||
/// **Named for the Repair verb**, which is 06-history-undo.md's vocabulary for exactly this act
|
||||
/// ("Repair duplicate of 'Fix login'" — the heal commit's own name) and survived the re-ruling
|
||||
/// intact: what changed is who starts it, not what it is called.
|
||||
///
|
||||
/// Its own case on `.relocateLooseFile`'s and `.displaceClaimedName`'s reasoning: this is work the
|
||||
/// *app* started on its own, on a folder the user copied in Finder without knowing it would
|
||||
/// collide, and a banner saying the app "couldn't move the item" would name a gesture that never
|
||||
/// happened. `title` is the withheld item's as the load found it — the name the user would
|
||||
/// recognize, and the one the successful notice uses.
|
||||
case repairDuplicateID(title: String?)
|
||||
|
||||
/// A Preview task-list checkbox being ticked or unticked (05-card-window.md ▸ Preview).
|
||||
///
|
||||
/// Its own case rather than a fold into `.rename`'s or `.style`'s neighbourhood, on the
|
||||
@@ -2634,9 +2723,12 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible {
|
||||
/// case is immutable once a caller has it in hand — there is nothing to "forget" later.
|
||||
public func withTitle(_ title: String?) -> WriteOperation {
|
||||
switch self {
|
||||
// `.repairDuplicateID` is identity here even though it carries a title: the remint never
|
||||
// opens an `index.md` — it is a rename — so there is no `readDocument` to enrich from, and
|
||||
// its title arrives already filled in from the load that detected the duplicate.
|
||||
case .createBoard, .createLane, .createCard, .importAttachment, .listAttachments,
|
||||
.removeAttachment, .renumberChildren, .relocateLooseFile, .agentGuide,
|
||||
.displaceClaimedName:
|
||||
.displaceClaimedName, .repairDuplicateID:
|
||||
self
|
||||
case .move: .move(title: title)
|
||||
case .reorder: .reorder(title: title)
|
||||
@@ -2684,6 +2776,7 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible {
|
||||
case let .relocateLooseFile(filename): "relocate loose file '\(filename)'"
|
||||
case .agentGuide: "update the agent guide"
|
||||
case let .displaceClaimedName(name): "move a stray '\(name)' aside"
|
||||
case let .repairDuplicateID(title): Self.phrase("repair the duplicate id of", title)
|
||||
case let .toggleTask(title): Self.phrase("toggle a checkbox in", title)
|
||||
case let .editBody(title): Self.phrase("save the body of", title)
|
||||
case let .rawSource(title): Self.phrase("apply source changes to", title)
|
||||
|
||||
Reference in New Issue
Block a user