diff --git a/Kanban/LiveStore/BannerCenter.swift b/Kanban/LiveStore/BannerCenter.swift index deb3f1d..d0908bd 100644 --- a/Kanban/LiveStore/BannerCenter.swift +++ b/Kanban/LiveStore/BannerCenter.swift @@ -524,6 +524,26 @@ public final class BannerCenter { postLoss(message) } + /// **The duplicate-id remint** (01-storage-format.md § Fractal layout ▸ Rules, re-ruled + /// 2026-07-29): two folders were carrying one id, the app gave the later one the fresh identity a + /// copy should have had, and this is the row that says so — "Announced, not invisible: the + /// relocation-style warning-tone notice names the repair … identity changed, so a line says so, + /// but nothing waits on consent". + /// + /// **A loss row, on `postRelocatedLooseFiles`' exact reasoning**, and the ruling asks for that + /// class by name: the app renamed a folder of the user's that they did not ask it to rename, so it + /// must be said out loud, must not evaporate unread, and must not rank as an error, because + /// nothing failed. It is emphatically **not** a condition banner with a button — the user-gated + /// Repair retired on 2026-07-29 and this row is what replaced it. + /// + /// `titles` are the reminted items' as the load found them, `nil` for an untitled one — "Untitled" + /// is a rendering, never a value (03-board-ui.md § Card face). A remint that reminted nothing + /// posts nothing. + public func postRemintedDuplicateIDs(_ titles: [String?]) { + guard let message = Self.remintedDuplicateIDsMessage(for: titles) else { return } + postLoss(message) + } + /// Posts the skipped-folders loss row for a Finder drop that imported its files but refused its /// folders (04-interactions.md ▸ Selection, drag & drop, "Folders are refused at hover"): "a /// mixed drag proposes for its files only, and the drop imports the files while a one-shot @@ -798,6 +818,13 @@ public final class BannerCenter { // held, the feature that needs it does not work, and the fix is theirs (move or rename // the thing sitting there) because the app has just demonstrated it cannot. "Couldn't move '\(name)' aside — Lanework needs that name" + case let .repairDuplicateID(title): + // **The failure's mirror of the success row** ("Repaired duplicate id — 'Fix login'"), in + // the same words, so the two read as one sentence and its negation. It names the *defect* + // rather than the mechanics ("couldn't rename a folder" would describe an act the user has + // no model of) and stays graceful when the item is untitled, because the enum knows a + // title and never a kind. + if let title { "Couldn't repair the duplicate id of '\(title)'" } else { "Couldn't repair a duplicate id" } case let .toggleTask(title): // The user's word for it, not the file's: they ticked a box. The card is named where // the read that preceded the flip learned its title, so a body write that refused says @@ -1019,6 +1046,28 @@ public final class BannerCenter { return "Renamed '\(only.name)' to '\(only.movedTo)' — Lanework needs that name" } + /// The duplicate-id remint's line — **the design's own sentence**, verbatim + /// (01-storage-format.md § Fractal layout ▸ Rules, re-ruled 2026-07-29: "posts 'Repaired duplicate + /// id — 'Fix login''"). + /// + /// - **One**: "Repaired duplicate id — 'Fix login'". The relocation's idiom exactly — the act + /// first, the subject after an em dash — and it names the *id* rather than the folder, because + /// the folder name is a UUID no user has a relationship with. There is no explanatory tail: the + /// act is its own explanation, and nothing about the user's content changed. + /// - **Several**: folded to a count, the idiom's plural — "Repaired 3 duplicate ids". Several + /// arrive together routinely (a hand-copied lane's cards all collide at once), and a sentence + /// naming each would be longer than the row. + /// + /// `nil` when nothing was reminted — a heal that healed nothing is not news, which is also the + /// vanished-duplicate race's whole outward appearance: silence. + public nonisolated static func remintedDuplicateIDsMessage(for titles: [String?]) -> String? { + guard let only = titles.first else { return nil } + guard titles.count == 1 else { + return "Repaired \(titles.count) duplicate ids" + } + return "Repaired duplicate id — \(sole(only))" + } + /// A sole migrated item's name: its title in quotes, or the untitled rendering the relocation /// line already uses ("an untitled card" / "an untitled lane" are one phrase here, because the /// clause it sits in already says which level it is). diff --git a/Kanban/LiveStore/BoardStore.swift b/Kanban/LiveStore/BoardStore.swift index 668ff8d..36496cc 100644 --- a/Kanban/LiveStore/BoardStore.swift +++ b/Kanban/LiveStore/BoardStore.swift @@ -269,6 +269,16 @@ public final class BoardStore: HealHost { defects.compactMap { if case let .claimedNameSquatted(work) = $0 { work } else { nil } } } + /// The duplicate ids the last load **withheld** from `snapshot` — a view over `defects`. + /// + /// The one defect whose subject is deliberately absent from the snapshot: the folders are on disk, + /// their content intact, and they are kept out of every snapshot so the one-item-per-id invariant + /// holds by construction (01-storage-format.md § Fractal layout ▸ Rules). `remintDuplicateIdentities()` + /// is what puts them back, under fresh ids. + public var duplicateIdentities: [DuplicateIdentity] { + defects.compactMap { if case let .duplicateIdentity(work) = $0 { work } else { nil } } + } + /// The standing read-side condition: the error from the last reload that failed, `nil` when the /// board is healthy. `BoardLoadError` already carries fail-fast's specifics — the offending path /// and what is wrong with it — which is the whole of what the banner needs to render @@ -3628,6 +3638,64 @@ public final class BoardStore: HealHost { } } + // MARK: - The duplicate-id remint + + /// Gives every duplicate id the last load withheld the fresh identity a copy should have had, and + /// posts one notice naming what was repaired — the **act** half of the duplicate-id rule + /// (01-storage-format.md § Fractal layout ▸ Rules; re-ruled 2026-07-29: "a silent heal, superseding + /// the former user-gated Repair banner", because "Lanework owns the board and re-mints identity at + /// will"). + /// + /// ### It is a heal, not a command + /// + /// This is the whole of the 2026-07-29 re-ruling in one method. What it replaced was a *condition + /// banner* offering a **Repair** button — a consent gate for a repair that is unambiguous and + /// content-lossless, which is exactly the class the ruling moved to app-initiated. So: no banner + /// row to raise or clear, no button, no Command Nexus row, nothing that waits. There is a notice + /// afterwards, because an identity changed and a heal that touches user content says so + /// (§ Validation and healing), and that is the only user-visible trace. + /// + /// **The withheld window is one heal cycle, not a standing condition** (02-architecture.md ▸ + /// Live-reload resilience): the load withholds, this remints, the reload that follows renders the + /// folder as an ordinary item under its new id. + /// + /// ### Its scheduling is the engine's, its ordering is not + /// + /// `HealScheduler` supplies the six steps — the resting-clear, the lock-and-writability gate (so a + /// read-only board **defers, never abandons**), the signature compare, the armed-before-attempt + /// memo, the one bracket, the notice and the clear-on-success. What is this heal's own is the Writer + /// call and the notice — and its place in `runScheduledHeals()`, which is deliberately *after* the + /// heals that write inside item folders: see that method. + /// + /// **It registers no undo step**, and per 13-native-undo.md that is a ruling rather than a + /// deferral: heals are not gestures, so nothing enters the stack, and undoing a remint would + /// recreate the duplicate id it exists to remove. + /// + /// **The write half re-verifies against disk**: `BoardWriter.remintDuplicateIdentity` checks both + /// that the folder is still there under the losing identity *and* that something else still carries + /// it, so a duplicate that vanished under the write — repaired on another device, hand-deleted — + /// contributes no rename and no line to the notice. + public func remintDuplicateIdentities() { + let work = duplicateIdentities + let root = rootURL + var reminted: [String?] = [] + heals.run( + .duplicateIdentity, + signature: Self.signature(of: work.map(IntegrityRules.Defect.duplicateIdentity)), + on: self + ) { () throws(BoardWriteError) -> Void in + for duplicate in work { + // The Writer answers `nil` when the duplicate resolved itself under us. + guard try BoardWriter.remintDuplicateIdentity(duplicate, inBoard: root) != nil else { + continue + } + reminted.append(duplicate.title) + } + } posting: { + .remintedDuplicateIDs(titles: reminted) + } + } + // MARK: - The agent guide /// Brings the board root's `CLAUDE.md` up to the current guide version, or leaves it exactly as @@ -3709,6 +3777,19 @@ public final class BoardStore: HealHost { /// disk changed. Running the displacement first closes the ruling's one-reload window inside a /// single pass, because a heal's write lands synchronously even though its reload does not. /// + /// **The duplicate-id remint goes after the two heals that write inside item folders, and that + /// ordering is load-bearing too** — for the mirror-image reason. A remint *renames a folder*, so + /// every path the same load handed the other healers below it would go stale the moment it ran: a + /// loose-file relocation aimed at a folder that had just been renamed underneath it would fail + /// loudly and banner about work the user never asked for. Running it last means each of them acts + /// on the paths the snapshot actually described, and the remint's own write is the last thing to + /// change the tree in the pass. + /// + /// It costs at most one extra reload in the rarest of overlaps (a duplicate that *also* carries a + /// legacy `deleted:` key): the migration moves it into `.trash/` first, the remint's path is stale, + /// the Writer's re-verify no-ops, and the next load finds the duplicate at its new path and heals + /// it. Self-healing beats a failure banner. + /// /// The rest of the order is immaterial: they touch disjoint files (a card's loose files, a /// `deleted:` key inside an `index.md`, `CLAUDE.md`), each opens its own bracket, and each is /// re-armed by the reload the others' writes produce, so none can see another's work half-done. @@ -3716,6 +3797,7 @@ public final class BoardStore: HealHost { displaceClaimedNames() relocateLooseCardFiles() migrateLegacyTombstones() + remintDuplicateIdentities() refreshAgentGuide() } diff --git a/Kanban/LiveStore/HealScheduler.swift b/Kanban/LiveStore/HealScheduler.swift index afbbc43..71e11bf 100644 --- a/Kanban/LiveStore/HealScheduler.swift +++ b/Kanban/LiveStore/HealScheduler.swift @@ -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) } } diff --git a/Kanban/Storage/BoardLoader.swift b/Kanban/Storage/BoardLoader.swift index 86d590c..f7ba27a 100644 --- a/Kanban/Storage/BoardLoader.swift +++ b/Kanban/Storage/BoardLoader.swift @@ -142,7 +142,10 @@ public enum BoardLoader: Sendable { // MARK: - Entry point - public static func load(boardRoot: URL) throws(BoardLoadError) -> LoadResult { + public static func load( + boardRoot: URL, + historyRanker: IdentityHistoryRanker? = nil + ) throws(BoardLoadError) -> LoadResult { try checkIsReadableDirectory(boardRoot) let boardIndexURL = boardRoot.appendingPathComponent(indexFileName) @@ -184,7 +187,11 @@ public enum BoardLoader: Sendable { warn(.boardLevelDeletedIgnored) } - var lanes: [Lane] = [] + // Every lane the walk read, in folder order — **not** `Lane` values yet. The board-wide + // identity dedupe below decides which folders render at all, and a `Lane` is built only on + // the far side of that decision, because a `Lane` carrying a withheld card would be exactly + // the snapshot the invariant forbids. + var walkedLanes: [WalkedLane] = [] for laneURL in try directoryCandidates(in: boardRoot) { let laneName = laneURL.lastPathComponent // The app-claimed board-root names are not strays and must not warn as such. Hidden @@ -257,21 +264,12 @@ public enum BoardLoader: Sendable { logger.info("\(laneName, privacy: .public): legacy 'deleted' key — lane to be returned live with the key removed") } - lanes.append(Lane( - id: ItemID(rawValue: laneName), + walkedLanes.append(WalkedLane( + name: laneName, schema: laneSchema, - title: laneDocument.title, - created: laneDocument.created, - modified: laneDocument.modified, - modifiedBy: laneDocument.modifiedBy, - deleted: laneDocument.deleted, - background: laneDocument.background, - icon: laneDocument.icon, - iconColor: laneDocument.iconColor, order: laneOrder, - width: laneDocument.width, - cards: Ranks.sortedForDisplay(cards, order: \.order, name: { $0.id.rawValue }), - document: laneDocument + document: laneDocument, + cards: Ranks.sortedForDisplay(cards, order: \.order, name: { $0.id.rawValue }) )) } @@ -301,6 +299,40 @@ public enum BoardLoader: Sendable { trash.append(entry) } + // **The board-wide identity dedupe** (01-storage-format.md § Fractal layout ▸ Rules: + // "Duplicate ids within a board are never tolerated … the loader keeps exactly one occurrence + // per id, board-wide"). Live lanes *and* `.trash/`, because board-wide means both containers — + // and a snapshot carrying two items with equal ids is the one thing SwiftUI's `ForEach` does + // not tolerate, which is why this is subtractive rather than advisory. + // + // Display order is settled here rather than in the `BoardModel` call below, because the + // dedupe's last tie-break *is* traversal order and the rule needs the occurrences in it. + let orderedLanes = Ranks.sortedForDisplay(walkedLanes, order: \.order, name: \.name) + let orderedTrash = Ranks.sortedForDisplay(trash, order: \.order, name: { $0.id.rawValue }) + let verdict = dedupeIdentities( + inBoardAt: boardRoot, + lanes: orderedLanes, + trash: orderedTrash, + historyRanker: historyRanker + ) + + // Case twins are the **tolerate** tier: a spelling artifact of the item that rendered, with + // nothing to do about it (§ Fractal layout ▸ Rules). They warn, exactly like every other + // skipped stray, and are deliberately absent from `defects`. + for twin in verdict.caseTwins { + warn(.caseTwinIgnored(path: twin.path, winner: twin.winner)) + } + // Content duplicates are **work**: withheld here, reminted by the store's scheduled heal + // (re-ruled 2026-07-29 — the loader itself still never writes). + for duplicate in verdict.duplicates { + defects.append(.duplicateIdentity(duplicate)) + logger.warning( + "\(duplicate.path, privacy: .public): duplicate id, withheld in favour of \(duplicate.winner, privacy: .public) — to be reminted" + ) + } + + let withheld = Set(verdict.duplicates.map(\.path) + verdict.caseTwins.map(\.path)) + let model = BoardModel( rootURL: boardRoot, schema: boardSchema, @@ -313,8 +345,8 @@ public enum BoardLoader: Sendable { icon: boardDocument.icon, iconColor: boardDocument.iconColor, template: boardDocument.value(for: templateKey), - lanes: Ranks.sortedForDisplay(lanes, order: \.order, name: { $0.id.rawValue }), - trash: Ranks.sortedForDisplay(trash, order: \.order, name: { $0.id.rawValue }), + lanes: orderedLanes.compactMap { $0.rendered(withholding: withheld) }, + trash: orderedTrash.filter { !withheld.contains(trashFolderName + "/" + $0.id.rawValue) }, document: boardDocument ) @@ -322,10 +354,172 @@ public enum BoardLoader: Sendable { model: model, warnings: warnings, defects: defects, - trashKinds: trashKinds + // Keyed by identity, so a withheld entry's reading has to go with it: two folders sharing + // an id would otherwise leave a `kind` answer standing for the *other* one — the exact + // ambiguity the dedupe exists to remove. + trashKinds: trashKinds.filter { !withheld.contains(trashFolderName + "/" + $0.key.rawValue) } ) } + // MARK: - The board-wide identity dedupe + + /// Every identity-bearing folder in the board, deduped — the loader's half of the rule whose + /// *deciding* is `IntegrityRules.dedupe(_:)`. + /// + /// **The gate comes first, and it is why this costs nothing on a healthy board**: a pass over the + /// names alone answers "does any identity appear twice at all", and on the overwhelmingly common + /// answer — no — nothing further happens: no `creationDateKey` reads, no history probes, no + /// grouping. The disk reads below are paid for only by a board that actually has a collision. + /// + /// **Withheld subtrees are still walked** (settled — the import boundary's finest-grain rule read + /// for the heal): a withheld *lane*'s cards are ordinary depth-2 occurrences and enter the + /// inventory like any other, so a collision nested inside a losing lane is its own withheld + /// occurrence with its own remint. Nothing deeper is an occurrence at all — "level is position", + /// and a UUID-shaped folder under a card is content. + /// + /// **The container side is told to the rule, not derived from the path**: this walk knows which + /// container it is in, and the container boundary is the rule's *first* tie-break (01-storage-format.md + /// § Fractal layout ▸ Rules, stated 2026-07-29 — a live occurrence keeps the identity regardless of + /// age), so re-deriving it from a `".trash/"` prefix downstream would be parsing back a fact that + /// was in hand here. + /// + /// Occurrence order is the traversal order the rule's *last* tie-break is stated in: lanes in + /// display order, each lane immediately followed by its cards in display order, then `.trash/`'s + /// flat entries in display order. The trash sits last only because the walk meets it last — + /// nothing rides on that any more, because the straddle case is decided by rung 0 long before + /// traversal order is consulted. + private static func dedupeIdentities( + inBoardAt root: URL, + lanes: [WalkedLane], + trash: [Card], + historyRanker: IdentityHistoryRanker? + ) -> IntegrityRules.DedupeVerdict { + typealias Container = IntegrityRules.IdentityOccurrence.Container + // (path, name, container, title) in traversal order — the inventory, before anything is read + // from disk. + var inventory: [(path: String, name: String, container: Container, title: String?)] = [] + for lane in lanes { + inventory.append(( + path: lane.name, + name: lane.name, + container: .live, + title: lane.document.title.value + )) + for card in lane.cards { + inventory.append(( + path: lane.name + "/" + card.id.rawValue, + name: card.id.rawValue, + container: .live, + title: card.title.value + )) + } + } + for entry in trash { + inventory.append(( + path: trashFolderName + "/" + entry.id.rawValue, + name: entry.id.rawValue, + container: .trashed, + title: entry.title.value + )) + } + + // The gate. + var seen: Set = [] + var collides = false + for entry in inventory where !seen.insert(IntegrityRules.canonicalIdentity(entry.name)).inserted { + collides = true + break + } + guard collides else { return IntegrityRules.DedupeVerdict(duplicates: [], caseTwins: []) } + + return IntegrityRules.dedupe(inventory.map { entry in + IntegrityRules.IdentityOccurrence( + path: entry.path, + name: entry.name, + container: entry.container, + title: entry.title, + birth: birthDate(of: root.appendingPathComponent(entry.path, isDirectory: true)), + historyRank: historyRanker?.rank(entry.path) + ) + }) + } + + /// A folder's filesystem birth date, `nil` when the volume does not keep one or the read fails — + /// the second rung of the earlier-occurrence-wins ladder (§ Fractal layout ▸ Rules: "without + /// history, the older folder (filesystem birth date) wins"). + /// + /// `nil` is a first-class answer rather than a fallback date: an unreadable birth date must drop + /// the comparison to traversal order, and substituting `.distantPast` here would silently make an + /// unreadable folder *win* every comparison it entered. + private static func birthDate(of folder: URL) -> Date? { + (try? folder.resourceValues(forKeys: [.creationDateKey]))?.creationDate + } + + /// One lane as the walk read it, before the dedupe decided what renders — a `Lane` minus the + /// decision, which is the only reason it exists rather than the walk building `Lane` values + /// directly. + /// + /// It carries the folder *name* rather than an `ItemID` because the dedupe's whole subject is + /// spelling and identity being different questions, and the verbatim name is what answers both. + private struct WalkedLane { + let name: String + let schema: Int + let order: Double + let document: FrontmatterDocument + /// Already in display order — the traversal the dedupe's last tie-break is stated in. + let cards: [Card] + + /// The `Lane` this becomes, or `nil` when the lane folder itself is withheld — a withheld + /// lane takes its subtree out of the snapshot with it, and its cards' own collisions were + /// already decided (they are occurrences in their own right). + func rendered(withholding withheld: Set) -> Lane? { + guard !withheld.contains(name) else { return nil } + return Lane( + id: ItemID(rawValue: name), + schema: schema, + title: document.title, + created: document.created, + modified: document.modified, + modifiedBy: document.modifiedBy, + deleted: document.deleted, + background: document.background, + icon: document.icon, + iconColor: document.iconColor, + order: order, + width: document.width, + cards: cards.filter { !withheld.contains(name + "/" + $0.id.rawValue) }, + document: document + ) + } + } + + // MARK: - The earlier-occurrence-wins history seam + + /// **Where git path history plugs into the duplicate-id winner rule** (01-storage-format.md + /// § Fractal layout ▸ Rules: "on git boards, the path history already tracks outranks the + /// newcomer (both tracked: the path that entered history first)"). + /// + /// A seam rather than an implementation because the first rung of that ladder is unbuildable in + /// base: base Lanework links no git machinery at all (12-editions.md; `scripts/verify-editions.sh` + /// scans the base binary to prove it), so the loader consults an injected ranker and falls through + /// to birth date and traversal order when there is none — which is every base board, and every Pro + /// board without a repo. + /// + /// Deliberately one closure and no protocol: the loader asks one question — "how early did this + /// path enter history" — and pro-m1's implementation answers it from `git log --diff-filter=A + /// --follow`-shaped plumbing behind the edition seam. `nil` means "untracked, or no history + /// here", which the rule reads as *outranked by anything tracked*. + /// + /// - Parameter rank: keyed by the occurrence's **board-root-relative path**, which is what a + /// repo's path history knows; lower is earlier. + public struct IdentityHistoryRanker: Sendable { + public let rank: @Sendable (String) -> Int? + + public init(rank: @escaping @Sendable (String) -> Int?) { + self.rank = rank + } + } + /// Whether a folder in `.trash/` has the *shape* of a lane — at least one identity-shaped child /// holding its own `index.md` (01-storage-format.md § Deletion: "UUID-shaped children with /// their own `index.md` → lane … else card"). @@ -739,6 +933,17 @@ public struct LoadResult: Sendable { public var claimedNameSquatters: [ClaimedNameSquatter] { defects.compactMap { if case let .claimedNameSquatted(work) = $0 { work } else { nil } } } + + /// The later occurrences this walk withheld from `model` — a **view over `defects`** + /// (01-storage-format.md § Fractal layout ▸ Rules; re-ruled 2026-07-29 — the silent remint). + /// + /// **The one defect the snapshot is already missing.** Every other defect names something that is + /// both on disk and in the model; a withheld duplicate is on disk and deliberately *not* in the + /// model, which is what makes the one-item-per-id invariant hold by construction rather than by + /// hope. In traversal order — which is the order the remint writes them and the notice names them. + public var duplicateIdentities: [DuplicateIdentity] { + defects.compactMap { if case let .duplicateIdentity(work) = $0 { work } else { nil } } + } } /// A tolerated anomaly the loader kept going past. Never blocks a load — see `BoardLoadError` @@ -763,6 +968,19 @@ public enum LoadWarning: Sendable, Equatable, CustomStringConvertible { /// (01-storage-format.md § Deletion) — ignored, never tombstones the board. case boardLevelDeletedIgnored + /// A folder whose name is a **case-spelled twin** of another occurrence of the same identity — + /// one item typed two ways (01-storage-format.md § Fractal layout ▸ Rules): a spelling some *live* + /// occurrence carries wins over one only trash ghosts carry (the container preference, stated + /// 2026-07-29), and among the candidates the canonical all-lowercase spelling wins where present, + /// else the lexicographically first. This is the loser. Skipped, preserved verbatim, never + /// rendered — **and never reminted**: it is a spelling artifact of the item that rendered, not a + /// copy, so there is nothing to heal. + /// + /// Its home is here rather than in the defect stream precisely because of that: a warning says + /// "this was ignored, it is staying exactly where it is, there is nothing to do", which is the + /// whole of the tolerate tier's verdict on it. Both paths are root-relative. + case caseTwinIgnored(path: String, winner: String) + public var description: String { switch self { case let .missingIndex(path): @@ -771,6 +989,8 @@ public enum LoadWarning: Sendable, Equatable, CustomStringConvertible { "\(path): folder name is not UUID-shaped, ignored as a stray" case .boardLevelDeletedIgnored: "index.md: board-level 'deleted' key is meaningless, ignored" + case let .caseTwinIgnored(path, winner): + "\(path): case-spelled twin of \(winner), ignored as a spelling artifact" } } } diff --git a/Kanban/Storage/BoardWriter.swift b/Kanban/Storage/BoardWriter.swift index 48ae2d8..e960fc7 100644 --- a/Kanban/Storage/BoardWriter.swift +++ b/Kanban/Storage/BoardWriter.swift @@ -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 { - var identities: Set = [] + 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) diff --git a/Kanban/Storage/IntegrityRules.swift b/Kanban/Storage/IntegrityRules.swift index 152c6f1..9c3d0ff 100644 --- a/Kanban/Storage/IntegrityRules.swift +++ b/Kanban/Storage/IntegrityRules.swift @@ -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** — `""`, `"/"`, + /// `".trash/"`. + /// + /// 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 1–3 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 + } +} diff --git a/KanbanTests/DuplicateIdentityTests.swift b/KanbanTests/DuplicateIdentityTests.swift new file mode 100644 index 0000000..6d70e73 --- /dev/null +++ b/KanbanTests/DuplicateIdentityTests.swift @@ -0,0 +1,1198 @@ +import Foundation +import Testing +@testable import Kanban + +/// **Duplicate ids within a board are never tolerated** (01-storage-format.md § Fractal layout ▸ +/// Rules, settled; the silent-remint re-ruling of 2026-07-29). +/// +/// Two folders can end up carrying one UUID however carefully the app mints them — a user copies a +/// card folder in Finder, an agent duplicates a tree, an archive is unpacked over a board. The +/// snapshot invariant does not bend for any of it: **one occurrence per id, board-wide**, because +/// SwiftUI's `ForEach` does not tolerate two equal ids and a board that crashes on load is the worst +/// failure a files-first app has. +/// +/// Two classes, two verdicts: +/// +/// - **Case-spelled twins** of one identity are spelling artifacts of the *same* item. The canonical +/// all-lowercase spelling wins where present, else the lexicographically first; the loser is a +/// silent stray — logged, preserved, never rendered, **never reminted** (reminting one would create +/// duplicate content the user never made). +/// - **Content duplicates** — distinct folders carrying the identical name string, necessarily under +/// different parents — are copies. Earlier-occurrence-wins; every later occurrence is withheld from +/// the snapshot and then **reminted by a scheduled heal** (re-ruled 2026-07-29 — the user-gated +/// Repair banner retired: "Lanework owns the board and re-mints identity at will"). + +// MARK: - Identities + +/// UUIDs with hex *letters* in them, because a case-twin fixture needs a name whose spelling can +/// actually change — `Ident.card1` is all digits and uppercases to itself. +private enum Dup { + /// The canonical, all-lowercase spelling. + static let lower = "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee" + /// The same identity, shouted. + static let upper = "AAAAAAAA-BBBB-4CCC-8DDD-EEEEEEEEEEEE" + /// The same identity again, spelled a third way — and lexicographically *after* `upper`, because + /// `"B" < "b"` in ASCII. + static let mixed = "AAAAAAAA-bbbb-4ccc-8ddd-eeeeeeeeeeee" + /// An unrelated identity, for boards that need a card that is nobody's twin. + static let other = "fedcba98-7654-4321-8fed-cba987654321" +} + +private func occurrence( + _ path: String, + _ name: String, + container: IntegrityRules.IdentityOccurrence.Container = .live, + title: String? = nil, + birth: Date? = nil, + historyRank: Int? = nil +) -> IntegrityRules.IdentityOccurrence { + IntegrityRules.IdentityOccurrence( + path: path, + name: name, + container: container, + title: title, + birth: birth, + historyRank: historyRank + ) +} + +private func date(_ offset: TimeInterval) -> Date { + Date(timeIntervalSince1970: 1_700_000_000 + offset) +} + +// MARK: - The rule + +/// `IntegrityRules.dedupe(_:)` is pure — an occurrence list in, two lists of losers out — so the rule +/// is pinned here without a filesystem in the way. +@Suite("Duplicate ids ▸ the dedupe rule") +struct DedupeRuleTests { + + @Test("A board with no repeated identity yields nothing") + func nothingToDo() { + let verdict = IntegrityRules.dedupe([ + occurrence(Ident.lane1, Ident.lane1), + occurrence("\(Ident.lane1)/\(Dup.lower)", Dup.lower), + occurrence("\(Ident.lane1)/\(Dup.other)", Dup.other), + ]) + #expect(verdict.duplicates.isEmpty) + #expect(verdict.caseTwins.isEmpty) + } + + /// The headline case: one hand copy, two lanes, identical name strings. + @Test("Two identical spellings under different parents: the later one is withheld") + func contentDuplicateWithholdsTheLater() { + let verdict = IntegrityRules.dedupe([ + occurrence(Ident.lane1, Ident.lane1), + occurrence("\(Ident.lane1)/\(Dup.lower)", Dup.lower, title: "Fix login"), + occurrence(Ident.lane2, Ident.lane2), + occurrence("\(Ident.lane2)/\(Dup.lower)", Dup.lower, title: "Fix login"), + ]) + + #expect(verdict.caseTwins.isEmpty, "identical spellings are copies, never spelling artifacts") + #expect(verdict.duplicates == [ + DuplicateIdentity( + path: "\(Ident.lane2)/\(Dup.lower)", + identity: Dup.lower, + title: "Fix login", + winner: "\(Ident.lane1)/\(Dup.lower)" + ), + ]) + } + + /// Rung two of the ladder outranks rung three: the older folder wins even when the traversal meets + /// it second. + @Test("Birth date beats traversal order") + func birthDateBeatsTraversal() { + let verdict = IntegrityRules.dedupe([ + occurrence("\(Ident.lane1)/\(Dup.lower)", Dup.lower, birth: date(100)), + occurrence("\(Ident.lane2)/\(Dup.lower)", Dup.lower, birth: date(0)), + ]) + #expect(verdict.duplicates.map(\.path) == ["\(Ident.lane1)/\(Dup.lower)"]) + #expect(verdict.duplicates.map(\.winner) == ["\(Ident.lane2)/\(Dup.lower)"]) + } + + @Test("Equal birth dates fall through to traversal order") + func equalDatesFallThrough() { + let verdict = IntegrityRules.dedupe([ + occurrence("\(Ident.lane1)/\(Dup.lower)", Dup.lower, birth: date(0)), + occurrence("\(Ident.lane2)/\(Dup.lower)", Dup.lower, birth: date(0)), + ]) + #expect(verdict.duplicates.map(\.path) == ["\(Ident.lane2)/\(Dup.lower)"]) + } + + /// **An unreadable date is no comparison at all.** Substituting `.distantPast` for a missing birth + /// date would silently make the unreadable folder win every comparison it entered. + @Test("One unreadable birth date drops the comparison to traversal order") + func oneMissingDateFallsThrough() { + // The folder *with* the older date is second in traversal; if a `nil` date were read as + // `.distantPast` the first would win, which is exactly what must not happen. + let verdict = IntegrityRules.dedupe([ + occurrence("\(Ident.lane1)/\(Dup.lower)", Dup.lower, birth: nil), + occurrence("\(Ident.lane2)/\(Dup.lower)", Dup.lower, birth: date(-10_000)), + ]) + #expect(verdict.duplicates.map(\.path) == ["\(Ident.lane2)/\(Dup.lower)"]) + } + + @Test("Neither date readable falls through to traversal order") + func noDatesFallThrough() { + let verdict = IntegrityRules.dedupe([ + occurrence("\(Ident.lane1)/\(Dup.lower)", Dup.lower), + occurrence("\(Ident.lane2)/\(Dup.lower)", Dup.lower), + ]) + #expect(verdict.duplicates.map(\.path) == ["\(Ident.lane2)/\(Dup.lower)"]) + } + + /// Rung one — the git seam. Base never fills it; pro-m1 does. + @Test("Git path history beats birth date") + func historyBeatsBirthDate() { + let verdict = IntegrityRules.dedupe([ + occurrence("\(Ident.lane1)/\(Dup.lower)", Dup.lower, birth: date(0), historyRank: 7), + occurrence("\(Ident.lane2)/\(Dup.lower)", Dup.lower, birth: date(100), historyRank: 3), + ]) + #expect(verdict.duplicates.map(\.path) == ["\(Ident.lane1)/\(Dup.lower)"]) + } + + /// "The path history already tracks outranks the newcomer" — read literally, and *against* the + /// birth date, which is the whole reason the rungs are ordered. + @Test("A tracked path outranks an untracked one whatever the dates say") + func trackedOutranksUntracked() { + let verdict = IntegrityRules.dedupe([ + occurrence("\(Ident.lane1)/\(Dup.lower)", Dup.lower, birth: date(0), historyRank: nil), + occurrence("\(Ident.lane2)/\(Dup.lower)", Dup.lower, birth: date(100), historyRank: 9), + ]) + #expect(verdict.duplicates.map(\.path) == ["\(Ident.lane1)/\(Dup.lower)"]) + } + + @Test("Equal history ranks fall through to the next rung") + func equalRanksFallThrough() { + let verdict = IntegrityRules.dedupe([ + occurrence("\(Ident.lane1)/\(Dup.lower)", Dup.lower, birth: date(100), historyRank: 4), + occurrence("\(Ident.lane2)/\(Dup.lower)", Dup.lower, birth: date(0), historyRank: 4), + ]) + #expect(verdict.duplicates.map(\.path) == ["\(Ident.lane1)/\(Dup.lower)"]) + } + + // MARK: The container boundary — rung 0 + + /// **The visible card never loses to its own ghost** (01-storage-format.md § Fractal layout ▸ + /// Rules, stated 2026-07-29): the straddle case is a restore done as a *copy* — an ⌥-drag out of + /// the trash in Finder — where the ghost left behind is genuinely the older folder. Every + /// age-based rung would withhold the card the user just restored; rung 0 is what stops them being + /// consulted at all. + @Test("A live occurrence beats an older trashed one") + func liveBeatsOlderTrashed() { + let verdict = IntegrityRules.dedupe([ + occurrence("\(Ident.lane1)/\(Dup.lower)", Dup.lower, birth: date(1000)), + occurrence(".trash/\(Dup.lower)", Dup.lower, container: .trashed, birth: date(0)), + ]) + #expect(verdict.duplicates.map(\.path) == [".trash/\(Dup.lower)"]) + #expect(verdict.duplicates.map(\.winner) == ["\(Ident.lane1)/\(Dup.lower)"]) + } + + /// Rung 0 sits **above history too**, not merely above age: an ⌥-drag restore leaves the tracked + /// path in the trash, so the ghost is both older *and* the one git knows. + @Test("A live occurrence beats a trashed one git says entered first") + func liveBeatsTrackedTrashed() { + let verdict = IntegrityRules.dedupe([ + occurrence("\(Ident.lane1)/\(Dup.lower)", Dup.lower, birth: date(1000), historyRank: nil), + occurrence(".trash/\(Dup.lower)", Dup.lower, container: .trashed, birth: date(0), historyRank: 1), + ]) + #expect(verdict.duplicates.map(\.path) == [".trash/\(Dup.lower)"]) + } + + /// Traversal order does not decide it either — a trashed occurrence met *first* still loses. + @Test("A trashed occurrence met first still loses") + func trashedMetFirstStillLoses() { + let verdict = IntegrityRules.dedupe([ + occurrence(".trash/\(Dup.lower)", Dup.lower, container: .trashed), + occurrence("\(Ident.lane1)/\(Dup.lower)", Dup.lower), + ]) + #expect(verdict.duplicates.map(\.path) == [".trash/\(Dup.lower)"]) + } + + /// **A group wholly inside the trash falls through to the ordinary ladder**, unchanged: rung 0 only + /// speaks when the occurrences straddle the boundary. + /// + /// The paths here are labels — the flat trash cannot really hold two entries with one name, so this + /// pins the *rule*'s fall-through rather than a reachable board. What makes it worth pinning is the + /// mistake it rules out: a container rung implemented as "trashed always loses" rather than as a + /// comparison would have no answer at all when both sides are trashed. + @Test("Two trashed occurrences fall through to the age ladder") + func bothTrashedFallThrough() { + let verdict = IntegrityRules.dedupe([ + occurrence(".trash/\(Dup.lower)", Dup.lower, container: .trashed, birth: date(1000)), + occurrence(".trash/nested/\(Dup.lower)", Dup.lower, container: .trashed, birth: date(0)), + ]) + #expect(verdict.duplicates.map(\.path) == [".trash/\(Dup.lower)"], "the older trashed one wins") + } + + /// Several live occurrences and a ghost: the ghost is out on rung 0, and the live ones settle it + /// among themselves on the age rungs. + @Test("The ghost is out first, then the live occurrences settle it by age") + func ghostOutThenAgeDecides() { + let verdict = IntegrityRules.dedupe([ + occurrence("\(Ident.lane1)/\(Dup.lower)", Dup.lower, birth: date(200)), + occurrence(".trash/\(Dup.lower)", Dup.lower, container: .trashed, birth: date(0)), + occurrence("\(Ident.lane2)/\(Dup.lower)", Dup.lower, birth: date(100)), + ]) + #expect(verdict.duplicates.map(\.path) == [ + "\(Ident.lane1)/\(Dup.lower)", + ".trash/\(Dup.lower)", + ]) + #expect(verdict.duplicates.map(\.winner) == Array(repeating: "\(Ident.lane2)/\(Dup.lower)", count: 2)) + } + + // MARK: Case twins + + /// The canonical spelling wins **wherever it sits in the traversal** — this is not an + /// earlier-occurrence question, it is a spelling question. + @Test("The all-lowercase spelling wins, however late it is met") + func lowercaseWins() { + let verdict = IntegrityRules.dedupe([ + occurrence("\(Ident.lane1)/\(Dup.upper)", Dup.upper, birth: date(0)), + occurrence("\(Ident.lane2)/\(Dup.lower)", Dup.lower, birth: date(100)), + ]) + #expect(verdict.duplicates.isEmpty, "a spelling artifact is never healed") + #expect(verdict.caseTwins == [ + CaseTwin(path: "\(Ident.lane1)/\(Dup.upper)", winner: "\(Ident.lane2)/\(Dup.lower)"), + ]) + } + + @Test("With no lowercase spelling present, the lexicographically first wins") + func lexicographicallyFirstWins() { + // "AAAAAAAA-BBBB-…" < "AAAAAAAA-bbbb-…" — uppercase sorts before lowercase in ASCII. + let verdict = IntegrityRules.dedupe([ + occurrence("\(Ident.lane1)/\(Dup.mixed)", Dup.mixed), + occurrence("\(Ident.lane2)/\(Dup.upper)", Dup.upper), + ]) + #expect(verdict.duplicates.isEmpty) + #expect(verdict.caseTwins == [ + CaseTwin(path: "\(Ident.lane1)/\(Dup.mixed)", winner: "\(Ident.lane2)/\(Dup.upper)"), + ]) + } + + /// **Mixed groups collapse spelling first, then apply earlier-occurrence-wins to what is left.** + /// The consequence is deliberate and documented: a copy whose case was *also* hand-changed + /// degrades to the silent case-twin posture and is never reminted. + @Test("Three occurrences spanning both classes: spelling collapses first") + func mixedGroupCollapsesSpellingFirst() { + let verdict = IntegrityRules.dedupe([ + occurrence("\(Ident.lane1)/\(Dup.lower)", Dup.lower, title: "Fix login", birth: date(0)), + occurrence("\(Ident.lane2)/\(Dup.lower)", Dup.lower, title: "Fix login", birth: date(100)), + occurrence("\(Ident.lane3)/\(Dup.upper)", Dup.upper, title: "Fix login", birth: date(200)), + ]) + + // The uppercase copy is a spelling artifact — silent, never reminted… + #expect(verdict.caseTwins == [ + CaseTwin(path: "\(Ident.lane3)/\(Dup.upper)", winner: "\(Ident.lane1)/\(Dup.lower)"), + ]) + // …and only the second *canonically spelled* occurrence is healed. + #expect(verdict.duplicates.map(\.path) == ["\(Ident.lane2)/\(Dup.lower)"]) + } + + /// The winner a case twin is named against is the earlier-occurrence winner, not merely the first + /// canonically spelled folder the walk met. + @Test("A case twin's named winner is the group's actual winner") + func caseTwinNamesTheRealWinner() { + let verdict = IntegrityRules.dedupe([ + occurrence("\(Ident.lane1)/\(Dup.upper)", Dup.upper), + occurrence("\(Ident.lane2)/\(Dup.lower)", Dup.lower, birth: date(100)), + occurrence("\(Ident.lane3)/\(Dup.lower)", Dup.lower, birth: date(0)), + ]) + #expect(verdict.caseTwins.map(\.winner) == ["\(Ident.lane3)/\(Dup.lower)"]) + #expect(verdict.duplicates.map(\.path) == ["\(Ident.lane2)/\(Dup.lower)"]) + } + + /// **The spelling contest obeys the container preference too.** Without it the canonical-wins rule + /// would hand the identity to the lowercase ghost in the trash and *skip* the live card as a + /// spelling artifact — the straddle read backwards, and the one outcome the ruling forbids: the + /// visible card would vanish while its ghost rendered from the trash. + /// + /// The twin stays a **twin**: skipped silently, preserved, never reminted. Only the winner + /// selection gained the container preference; the spelling-artifacts-stay-silent rule is unchanged. + @Test("An uppercase live occurrence beats a lowercase trashed twin") + func liveSpellingBeatsTrashedCanonicalSpelling() { + let verdict = IntegrityRules.dedupe([ + occurrence("\(Ident.lane1)/\(Dup.upper)", Dup.upper, birth: date(1000)), + occurrence(".trash/\(Dup.lower)", Dup.lower, container: .trashed, birth: date(0)), + ]) + #expect(verdict.duplicates.isEmpty, "a spelling artifact is never reminted, trashed or not") + #expect(verdict.caseTwins == [ + CaseTwin(path: ".trash/\(Dup.lower)", winner: "\(Ident.lane1)/\(Dup.upper)"), + ]) + } + + /// Among *live* spellings the ordinary rule still decides — the container filter narrows the + /// candidates, it does not replace canonical-else-lexicographic. + @Test("Among live spellings, canonical still wins") + func canonicalStillWinsAmongLiveSpellings() { + let verdict = IntegrityRules.dedupe([ + occurrence("\(Ident.lane1)/\(Dup.upper)", Dup.upper, birth: date(0)), + occurrence("\(Ident.lane2)/\(Dup.lower)", Dup.lower, birth: date(1000)), + occurrence(".trash/\(Dup.mixed)", Dup.mixed, container: .trashed, birth: date(-1000)), + ]) + #expect(verdict.duplicates.isEmpty) + #expect(verdict.caseTwins.map(\.path).sorted() == [ + ".trash/\(Dup.mixed)", + "\(Ident.lane1)/\(Dup.upper)", + ].sorted()) + #expect(verdict.caseTwins.allSatisfy { $0.winner == "\(Ident.lane2)/\(Dup.lower)" }) + } + + /// A wholly trashed group has no live candidates, so the spelling contest is exactly what it was. + @Test("A wholly trashed group picks its spelling the old way") + func whollyTrashedSpellingUnchanged() { + let verdict = IntegrityRules.dedupe([ + occurrence(".trash/\(Dup.upper)", Dup.upper, container: .trashed, birth: date(0)), + occurrence(".trash/nested/\(Dup.lower)", Dup.lower, container: .trashed, birth: date(1000)), + ]) + #expect(verdict.duplicates.isEmpty) + #expect(verdict.caseTwins == [ + CaseTwin(path: ".trash/\(Dup.upper)", winner: ".trash/nested/\(Dup.lower)"), + ]) + } + + // MARK: Determinism and shape + + /// **Exactly one survivor per identity, whatever the group looks like** — the invariant the whole + /// pass exists for, over a deliberately nasty group of five. + @Test("Every group keeps exactly one occurrence") + func exactlyOneSurvivor() { + let occurrences = [ + occurrence("\(Ident.lane1)/\(Dup.lower)", Dup.lower, birth: date(50)), + occurrence("\(Ident.lane2)/\(Dup.upper)", Dup.upper, birth: date(10)), + occurrence("\(Ident.lane3)/\(Dup.lower)", Dup.lower, birth: date(20)), + occurrence("\(Ident.lane4)/\(Dup.mixed)", Dup.mixed, birth: date(0)), + occurrence(".trash/\(Dup.lower)", Dup.lower, container: .trashed, birth: date(90)), + ] + let verdict = IntegrityRules.dedupe(occurrences) + + let lost = Set(verdict.duplicates.map(\.path)).union(verdict.caseTwins.map(\.path)) + #expect(lost.count == 4) + #expect(occurrences.filter { !lost.contains($0.path) }.map(\.path) == ["\(Ident.lane3)/\(Dup.lower)"]) + } + + /// The output order is the traversal's, not a dictionary's — the notice's subjects and the log's + /// lines must read in board order, and the same input must produce the same answer every time. + @Test("Losers come back in traversal order, repeatably") + func traversalOrderedAndStable() { + let occurrences = [ + occurrence("\(Ident.lane1)/\(Dup.lower)", Dup.lower), + occurrence("\(Ident.lane1)/\(Dup.other)", Dup.other), + occurrence("\(Ident.lane2)/\(Dup.lower)", Dup.lower), + occurrence("\(Ident.lane2)/\(Dup.other)", Dup.other), + occurrence("\(Ident.lane3)/\(Dup.lower)", Dup.lower), + ] + let expected = [ + "\(Ident.lane2)/\(Dup.lower)", + "\(Ident.lane2)/\(Dup.other)", + "\(Ident.lane3)/\(Dup.lower)", + ] + for _ in 0..<20 { + #expect(IntegrityRules.dedupe(occurrences).duplicates.map(\.path) == expected) + } + } + + /// Identity comparison is UUID-*value* equality, so an occurrence's `identity` is its name folded + /// — and that is what the defect carries, never the verbatim spelling. + @Test("A duplicate's identity is canonical, never the folder's spelling") + func identityIsCanonical() { + let verdict = IntegrityRules.dedupe([ + occurrence("\(Ident.lane1)/\(Dup.upper)", Dup.upper), + occurrence("\(Ident.lane2)/\(Dup.upper)", Dup.upper), + ]) + #expect(verdict.duplicates.map(\.identity) == [Dup.lower]) + } +} + +// MARK: - Fixtures + +@MainActor +private func makeBoard() throws -> WriterFixture { + let fixture = try WriterFixture() + try fixture.item("", Item.board) + try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo")) + try fixture.item(Ident.lane2, Item.rich(order: "2048", title: "Doing")) + return fixture +} + +/// Every identity the snapshot renders, canonically — lanes, their cards, and the trash. +private func renderedIdentities(_ model: BoardModel) -> [String] { + var identities: [String] = [] + for lane in model.lanes { + identities.append(IntegrityRules.canonicalIdentity(lane.id.rawValue)) + identities.append(contentsOf: lane.cards.map { IntegrityRules.canonicalIdentity($0.id.rawValue) }) + } + identities.append(contentsOf: model.trash.map { IntegrityRules.canonicalIdentity($0.id.rawValue) }) + return identities +} + +private func setBirth(_ url: URL, _ when: Date) throws { + try FileManager.default.setAttributes([.creationDate: when], ofItemAtPath: url.path) +} + +@MainActor +private final class BracketLog { + private(set) var begins = 0 + + func attach(to store: BoardStore) { + store.watcherBrackets = (begin: { self.begins += 1 }, end: {}) + } +} + +@MainActor +private final class StepLog: HistoryProviding { + var canUndo = false + var canRedo = false + var undoActionName: String? + var redoActionName: String? + private(set) var registered: [String] = [] + + func register(_ step: HistoryStep) { registered.append(step.name) } + func undo() {} + func redo() {} + func clear() {} +} + +// MARK: - Detection, through the real loader + +/// Detection is **read-only in the loader** — the walk reports and withholds, the store acts. +@MainActor +@Suite("Duplicate ids ▸ detection") +struct DuplicateIdentityDetectionTests { + + @Test("A card hand-copied into another lane loads once, and the copy is reported") + func copiedCardLoadsOnce() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let original = try fixture.item("\(Ident.lane1)/\(Dup.lower)", Item.rich(order: "1024", title: "Fix login")) + let copy = try fixture.item("\(Ident.lane2)/\(Dup.lower)", Item.rich(order: "1024", title: "Fix login")) + try setBirth(original, date(0)) + try setBirth(copy, date(1000)) + + let result = try BoardLoader.load(boardRoot: fixture.root) + + // One occurrence per id, board-wide — the invariant. + #expect(renderedIdentities(result.model).sorted() == renderedIdentities(result.model).sorted()) + #expect(Set(renderedIdentities(result.model)).count == renderedIdentities(result.model).count) + #expect(result.model.lanes.first { $0.id.rawValue == Ident.lane1 }?.cards.count == 1) + #expect(result.model.lanes.first { $0.id.rawValue == Ident.lane2 }?.cards.isEmpty == true) + + #expect(result.duplicateIdentities == [ + DuplicateIdentity( + path: "\(Ident.lane2)/\(Dup.lower)", + identity: Dup.lower, + title: "Fix login", + winner: "\(Ident.lane1)/\(Dup.lower)" + ), + ]) + // Nothing was moved, nothing was written: the loader never writes. + #expect(fixture.exists("\(Ident.lane2)/\(Dup.lower)/index.md")) + #expect(try fixture.indexText("\(Ident.lane2)/\(Dup.lower)") == Item.rich(order: "1024", title: "Fix login")) + } + + /// A healthy board pays nothing for this pass and reports nothing — the gate. + @Test("A board with no duplicates reports none") + func healthyBoardIsQuiet() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + try fixture.item("\(Ident.lane1)/\(Dup.lower)", Item.rich(order: "1024", title: "Fix login")) + try fixture.item("\(Ident.lane2)/\(Dup.other)", Item.rich(order: "1024", title: "Other")) + + let result = try BoardLoader.load(boardRoot: fixture.root) + #expect(result.defects.isEmpty) + #expect(result.warnings.isEmpty) + #expect(result.duplicateIdentities.isEmpty) + } + + /// **Board-wide spans both containers**: a card in the trash and a card in a lane sharing one id + /// would put two items with equal ids into one snapshot the moment either was rendered. + @Test("The trash counts — a lane card and a trash card sharing an id dedupe") + func trashCounts() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let live = try fixture.item("\(Ident.lane1)/\(Dup.lower)", Item.rich(order: "1024", title: "Fix login")) + let trashed = try fixture.item(".trash/\(Dup.lower)", Item.rich(order: "1024", title: "Fix login")) + try setBirth(live, date(0)) + try setBirth(trashed, date(1000)) + + let result = try BoardLoader.load(boardRoot: fixture.root) + + #expect(result.model.trash.isEmpty) + #expect(result.model.lanes.first { $0.id.rawValue == Ident.lane1 }?.cards.count == 1) + #expect(result.duplicateIdentities.map(\.path) == [".trash/\(Dup.lower)"]) + // The trash's `kind` reading goes with the withheld entry: leaving it would stand as an answer + // about the *live* card, which is the ambiguity the dedupe exists to remove. + #expect(result.trashKinds.isEmpty) + } + + /// **The restore-as-a-copy straddle, end to end** (01-storage-format.md § Fractal layout ▸ Rules, + /// stated 2026-07-29): the user ⌥-dragged a card out of the trash in Finder, so the ghost left + /// behind is the *older* folder. Earlier-entrant-wins alone would withhold the very card they just + /// restored and render its ghost from the trash; the container boundary is the first tie-break + /// precisely so that cannot happen. + @Test("A live card outranks its own older ghost in the trash") + func liveCardOutranksItsGhost() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let live = try fixture.item("\(Ident.lane1)/\(Dup.lower)", Item.rich(order: "1024", title: "Fix login")) + let ghost = try fixture.item(".trash/\(Dup.lower)", Item.rich(order: "1024", title: "Fix login")) + try setBirth(live, date(1000)) + try setBirth(ghost, date(0)) + + let result = try BoardLoader.load(boardRoot: fixture.root) + + // The restored card renders; the ghost is the withheld one. + #expect(result.model.lanes.first { $0.id.rawValue == Ident.lane1 }?.cards.count == 1) + #expect(result.model.trash.isEmpty) + #expect(result.duplicateIdentities.map(\.path) == [".trash/\(Dup.lower)"]) + #expect(result.duplicateIdentities.map(\.winner) == ["\(Ident.lane1)/\(Dup.lower)"]) + } + + /// The same straddle with git in the picture: an ⌥-drag restore leaves the *tracked* path in the + /// trash, so the ghost is the one history knows — and still loses. + @Test("A tracked ghost still loses to the untracked live card") + func trackedGhostStillLoses() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let live = try fixture.item("\(Ident.lane1)/\(Dup.lower)", Item.rich(order: "1024", title: "Fix login")) + let ghost = try fixture.item(".trash/\(Dup.lower)", Item.rich(order: "1024", title: "Fix login")) + try setBirth(live, date(1000)) + try setBirth(ghost, date(0)) + let ranker = BoardLoader.IdentityHistoryRanker { path in + path == ".trash/\(Dup.lower)" ? 1 : nil + } + + let result = try BoardLoader.load(boardRoot: fixture.root, historyRanker: ranker) + + #expect(result.duplicateIdentities.map(\.path) == [".trash/\(Dup.lower)"]) + #expect(result.model.lanes.first { $0.id.rawValue == Ident.lane1 }?.cards.count == 1) + } + + /// **The same preference governs a trashed lane sharing a live lane's UUID** — the ruling says so + /// explicitly, and it needs no special case: a trashed lane is a `.trash/` entry like any other. + @Test("A trashed lane loses to the live lane sharing its UUID") + func trashedLaneLosesToLiveLane() throws { + let fixture = try WriterFixture() + defer { fixture.tearDown() } + try fixture.item("", Item.board) + let live = try fixture.item(Dup.lower, Item.rich(order: "1024", title: "Todo")) + // A trashed lane: a `.trash/` entry with identity-shaped children of its own. + let trashed = try fixture.item(".trash/\(Dup.lower)", Item.rich(order: "1024", title: "Todo")) + try fixture.item(".trash/\(Dup.lower)/\(Dup.other)", Item.rich(order: "1024", title: "Nested")) + try setBirth(live, date(1000)) + try setBirth(trashed, date(0)) + + let result = try BoardLoader.load(boardRoot: fixture.root) + + #expect(result.model.lanes.map { $0.id.rawValue } == [Dup.lower]) + #expect(result.model.trash.isEmpty) + #expect(result.duplicateIdentities.map(\.path) == [".trash/\(Dup.lower)"]) + } + + /// The case-twin straddle through the real loader: the live card is spelled uppercase, its ghost + /// canonically. The live card renders, the ghost is a **silent twin** — skipped, preserved, and + /// never reminted, so no heal is scheduled at all. + @Test("A live uppercase card outranks its canonically spelled ghost, silently") + func liveUppercaseBeatsCanonicalGhost() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let live = try fixture.item("\(Ident.lane1)/\(Dup.upper)", Item.rich(order: "1024", title: "Fix login")) + let ghost = try fixture.item(".trash/\(Dup.lower)", Item.rich(order: "1024", title: "Fix login")) + try setBirth(live, date(1000)) + try setBirth(ghost, date(0)) + + let result = try BoardLoader.load(boardRoot: fixture.root) + + #expect(result.model.lanes.first { $0.id.rawValue == Ident.lane1 }?.cards.count == 1) + #expect(result.model.trash.isEmpty) + #expect(result.defects.isEmpty, "a spelling artifact is never work") + #expect(result.warnings == [ + .caseTwinIgnored(path: ".trash/\(Dup.lower)", winner: "\(Ident.lane1)/\(Dup.upper)"), + ]) + #expect(fixture.exists(".trash/\(Dup.lower)/index.md"), "preserved verbatim") + } + + /// **Levels are not identity namespaces.** A lane folder copied *into* a lane becomes a + /// card-level folder carrying the lane's id — one identity, two folders, two levels. + @Test("A lane and a card sharing an id dedupe across levels") + func crossLevelDuplicate() throws { + let fixture = try WriterFixture() + defer { fixture.tearDown() } + try fixture.item("", Item.board) + let lane = try fixture.item(Dup.lower, Item.rich(order: "1024", title: "Todo")) + try fixture.item(Ident.lane2, Item.rich(order: "2048", title: "Doing")) + let nested = try fixture.item("\(Ident.lane2)/\(Dup.lower)", Item.rich(order: "1024", title: "Copied lane")) + try setBirth(lane, date(0)) + try setBirth(nested, date(1000)) + + let result = try BoardLoader.load(boardRoot: fixture.root) + + #expect(result.model.lanes.map { $0.id.rawValue }.sorted() == [Dup.lower, Ident.lane2].sorted()) + #expect(result.model.lanes.first { $0.id.rawValue == Ident.lane2 }?.cards.isEmpty == true) + #expect(result.duplicateIdentities.map(\.path) == ["\(Ident.lane2)/\(Dup.lower)"]) + } + + /// **A withheld lane takes its subtree out of the snapshot** — and its cards are still occurrences + /// in their own right, so a collision nested inside a losing lane is its own withheld occurrence + /// with its own remint (the import boundary's finest-grain rule, read for the heal). + @Test("A withheld lane's cards are still their own occurrences") + func withheldLaneSubtreeStillCollides() throws { + let fixture = try WriterFixture() + defer { fixture.tearDown() } + try fixture.item("", Item.board) + // `/` (a lane) and `//` (a card) share an identity; the lane + // loses, so its whole subtree leaves the snapshot. + try fixture.item(Ident.lane2, Item.rich(order: "2048", title: "Doing")) + let nested = try fixture.item("\(Ident.lane2)/\(Dup.lower)", Item.rich(order: "1024", title: "Copied lane")) + let lane = try fixture.item(Dup.lower, Item.rich(order: "1024", title: "Todo")) + try setBirth(nested, date(0)) + try setBirth(lane, date(1000)) + // The losing lane's own card collides with a card in the surviving lane. + let insideLoser = try fixture.item("\(Dup.lower)/\(Dup.other)", Item.rich(order: "1024", title: "Nested")) + let insideWinner = try fixture.item("\(Ident.lane2)/\(Dup.other)", Item.rich(order: "2048", title: "Nested")) + try setBirth(insideWinner, date(0)) + try setBirth(insideLoser, date(1000)) + + let result = try BoardLoader.load(boardRoot: fixture.root) + + #expect(result.model.lanes.map { $0.id.rawValue } == [Ident.lane2]) + // Both the losing lane *and* the collision nested inside it are withheld occurrences, each + // with its own remint to come. + #expect(Set(result.duplicateIdentities.map(\.path)) == [ + Dup.lower, + "\(Dup.lower)/\(Dup.other)", + ]) + } + + // MARK: Case twins + + /// Case twins are the **tolerate** tier: a warning, not a defect — nothing to heal. + /// + /// Placed under different parents deliberately: default APFS is case-insensitive, so two + /// case-spelled *siblings* cannot exist there at all. + @Test("A case-spelled twin is a silent stray, not a defect") + func caseTwinIsSilent() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + try fixture.item("\(Ident.lane1)/\(Dup.upper)", Item.rich(order: "1024", title: "Fix login")) + try fixture.item("\(Ident.lane2)/\(Dup.lower)", Item.rich(order: "1024", title: "Fix login")) + + let result = try BoardLoader.load(boardRoot: fixture.root) + + #expect(result.defects.isEmpty, "a spelling artifact is never work") + #expect(result.warnings == [ + .caseTwinIgnored(path: "\(Ident.lane1)/\(Dup.upper)", winner: "\(Ident.lane2)/\(Dup.lower)"), + ]) + // The canonical spelling renders; the twin is preserved verbatim and never rendered. + #expect(result.model.lanes.first { $0.id.rawValue == Ident.lane1 }?.cards.isEmpty == true) + #expect(result.model.lanes.first { $0.id.rawValue == Ident.lane2 }?.cards.count == 1) + #expect(fixture.exists("\(Ident.lane1)/\(Dup.upper)/index.md")) + } + + @Test("With no canonical spelling, the lexicographically first renders") + func lexicographicWinnerRenders() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + try fixture.item("\(Ident.lane1)/\(Dup.mixed)", Item.rich(order: "1024", title: "Fix login")) + try fixture.item("\(Ident.lane2)/\(Dup.upper)", Item.rich(order: "1024", title: "Fix login")) + + let result = try BoardLoader.load(boardRoot: fixture.root) + + #expect(result.defects.isEmpty) + #expect(result.model.lanes.first { $0.id.rawValue == Ident.lane2 }?.cards.count == 1) + #expect(result.warnings.map(\.description) == [ + "\(Ident.lane1)/\(Dup.mixed): case-spelled twin of \(Ident.lane2)/\(Dup.upper), ignored as a spelling artifact", + ]) + } + + // MARK: The history seam + + /// The seam base can never fill: an injected ranker decides the winner ahead of the birth dates. + @Test("An injected history ranker outranks the filesystem") + func historyRankerDecides() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let original = try fixture.item("\(Ident.lane1)/\(Dup.lower)", Item.rich(order: "1024", title: "Fix login")) + let copy = try fixture.item("\(Ident.lane2)/\(Dup.lower)", Item.rich(order: "1024", title: "Fix login")) + try setBirth(original, date(0)) + try setBirth(copy, date(1000)) + + // Without a ranker the older folder wins… + #expect(try BoardLoader.load(boardRoot: fixture.root).duplicateIdentities.map(\.path) + == ["\(Ident.lane2)/\(Dup.lower)"]) + + // …and with one that says the newer path entered history first, it does not. + let ranker = BoardLoader.IdentityHistoryRanker { path in + path == "\(Ident.lane2)/\(Dup.lower)" ? 1 : nil + } + #expect(try BoardLoader.load(boardRoot: fixture.root, historyRanker: ranker).duplicateIdentities.map(\.path) + == ["\(Ident.lane1)/\(Dup.lower)"]) + } + + /// Three copies of one card: two are withheld, one renders — and the notice folds. + @Test("Three occurrences leave one standing") + func threeCopiesLeaveOne() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + try fixture.item(Ident.lane3, Item.rich(order: "3072", title: "Done")) + let first = try fixture.item("\(Ident.lane1)/\(Dup.lower)", Item.rich(order: "1024", title: "Fix login")) + let second = try fixture.item("\(Ident.lane2)/\(Dup.lower)", Item.rich(order: "1024", title: "Fix login")) + let third = try fixture.item("\(Ident.lane3)/\(Dup.lower)", Item.rich(order: "1024", title: "Fix login")) + try setBirth(first, date(0)) + try setBirth(second, date(100)) + try setBirth(third, date(200)) + + let result = try BoardLoader.load(boardRoot: fixture.root) + + #expect(result.duplicateIdentities.map(\.path) == [ + "\(Ident.lane2)/\(Dup.lower)", + "\(Ident.lane3)/\(Dup.lower)", + ]) + #expect(renderedIdentities(result.model).filter { $0 == Dup.lower }.count == 1) + } +} + +// MARK: - The remint + +/// The Writer's half — a rename, and nothing else. +@MainActor +@Suite("Duplicate ids ▸ the remint") +struct DuplicateIdentityRemintTests { + + private func board() throws -> (WriterFixture, DuplicateIdentity) { + let fixture = try makeBoard() + try fixture.item("\(Ident.lane1)/\(Dup.lower)", Item.rich(order: "1024", title: "Fix login")) + try fixture.item("\(Ident.lane2)/\(Dup.lower)", Item.rich(order: "1024", title: "Fix login")) + return (fixture, DuplicateIdentity( + path: "\(Ident.lane2)/\(Dup.lower)", + identity: Dup.lower, + title: "Fix login", + winner: "\(Ident.lane1)/\(Dup.lower)" + )) + } + + /// **A fresh lowercase v4, content untouched, nothing stamped.** The remint is an identity repair, + /// not an edit — the folder's `index.md` is never opened, so `modified` and `modified-by` are + /// exactly what they were. + @Test("It renames to a fresh lowercase v4 and leaves everything else alone") + func remintsToAFreshName() throws { + let (fixture, duplicate) = try board() + defer { fixture.tearDown() } + let before = try fixture.indexText("\(Ident.lane2)/\(Dup.lower)") + + let minted = try BoardWriter.remintDuplicateIdentity(duplicate, inBoard: fixture.root) + + let fresh = try #require(minted?.rawValue) + #expect(fresh != Dup.lower) + #expect(fresh == fresh.lowercased(), "the app emits lowercase") + #expect(UUID(uuidString: fresh) != nil) + #expect(!fixture.exists("\(Ident.lane2)/\(Dup.lower)"), "the losing name is gone") + #expect(try fixture.indexText("\(Ident.lane2)/\(fresh)") == before, "byte-verbatim, no stamp") + // The winner never moved. + #expect(fixture.exists("\(Ident.lane1)/\(Dup.lower)/index.md")) + } + + /// Children travel with the folder — the rename never opens anything inside it. + @Test("Attachments, strays and nested folders travel with the rename") + func childrenTravel() throws { + let (fixture, duplicate) = try board() + defer { fixture.tearDown() } + try fixture.file("\(Ident.lane2)/\(Dup.lower)/attachments/shot.png", Data("png".utf8)) + try fixture.file("\(Ident.lane2)/\(Dup.lower)/notes.txt", Data("loose".utf8)) + + let fresh = try #require(try BoardWriter.remintDuplicateIdentity(duplicate, inBoard: fixture.root)?.rawValue) + + #expect(try fixture.data("\(Ident.lane2)/\(fresh)/attachments/shot.png") == Data("png".utf8)) + #expect(try fixture.data("\(Ident.lane2)/\(fresh)/notes.txt") == Data("loose".utf8)) + } + + /// **The re-verify, first half** — the folder is gone. Losing the race to a foreign fix is + /// success, never an error. + @Test("A duplicate hand-deleted under the write is a no-op") + func vanishedFolderIsANoOp() throws { + let (fixture, duplicate) = try board() + defer { fixture.tearDown() } + try FileManager.default.removeItem(at: fixture.url("\(Ident.lane2)/\(Dup.lower)")) + + #expect(try BoardWriter.remintDuplicateIdentity(duplicate, inBoard: fixture.root) == nil) + } + + /// **The re-verify, second half** — the *winner* is gone, so this folder is no longer a duplicate + /// of anything and reminting it would change an identity for no reason at all. + @Test("A vanished winner makes the remint a no-op") + func vanishedWinnerIsANoOp() throws { + let (fixture, duplicate) = try board() + defer { fixture.tearDown() } + try FileManager.default.removeItem(at: fixture.url("\(Ident.lane1)/\(Dup.lower)")) + + #expect(try BoardWriter.remintDuplicateIdentity(duplicate, inBoard: fixture.root) == nil) + #expect(fixture.exists("\(Ident.lane2)/\(Dup.lower)/index.md"), "left exactly as it was") + } + + /// Idempotent by the same re-verify: a second attempt on already-repaired work does nothing. + @Test("Reminting twice remints once") + func remintingTwiceRemintsOnce() throws { + let (fixture, duplicate) = try board() + defer { fixture.tearDown() } + + let fresh = try #require(try BoardWriter.remintDuplicateIdentity(duplicate, inBoard: fixture.root)?.rawValue) + #expect(try BoardWriter.remintDuplicateIdentity(duplicate, inBoard: fixture.root) == nil) + #expect(fixture.exists("\(Ident.lane2)/\(fresh)/index.md")) + } + + /// A case twin is never handed to this call by the loader — but if one were, the winner probe is + /// what still makes it safe: the twin *is* a second occurrence, so the identity check is on the + /// canonical value and the rename would be honest. What must not happen is a remint of a folder + /// whose spelling no longer matches the defect that named it. + @Test("A folder already reminted under us is not reminted again") + func alreadyRemintedIsANoOp() throws { + let (fixture, duplicate) = try board() + defer { fixture.tearDown() } + try fixture.moveFolder("\(Ident.lane2)/\(Dup.lower)", to: "\(Ident.lane2)/\(Dup.other)") + + #expect(try BoardWriter.remintDuplicateIdentity(duplicate, inBoard: fixture.root) == nil) + #expect(fixture.exists("\(Ident.lane2)/\(Dup.other)/index.md")) + } + + /// A rename is a move as far as provenance goes, and **heal-marked**, because the app started it + /// on its own — the receipt is what splits it into its own commit on git boards. + @Test("It drops a heal-marked move receipt") + func dropsAHealMarkedReceipt() throws { + let (fixture, duplicate) = try board() + defer { fixture.tearDown() } + let ledger = EchoLedger() + let from = fixture.url("\(Ident.lane2)/\(Dup.lower)") + + let fresh = try EchoLedger.$current.withValue(ledger) { + try BoardWriter.remintDuplicateIdentity(duplicate, inBoard: fixture.root)?.rawValue + } + + let to = fixture.url("\(Ident.lane2)/\(try #require(fresh))") + #expect(ledger.receipt(at: from) == .move(from: EchoLedger.key(from), to: EchoLedger.key(to))) + #expect(ledger.isHeal(at: to)) + } + + /// The fresh name is minted away from **every** identity in the board, not merely from this + /// parent's children: trading one duplicate for another would be the one unacceptable outcome. + @Test("The minted name collides with nothing board-wide") + func mintedNameIsBoardWideUnique() throws { + let (fixture, duplicate) = try board() + defer { fixture.tearDown() } + try fixture.item("\(Ident.lane1)/\(Dup.other)", Item.rich(order: "2048", title: "Other")) + try fixture.item(".trash/\(Ident.card3)", Item.rich(order: "1024", title: "Trashed")) + + let fresh = try #require(try BoardWriter.remintDuplicateIdentity(duplicate, inBoard: fixture.root)?.rawValue) + + let result = try BoardLoader.load(boardRoot: fixture.root) + #expect(result.defects.isEmpty, "no duplicate left to find") + #expect(renderedIdentities(result.model).contains(fresh)) + #expect(Set(renderedIdentities(result.model)).count == renderedIdentities(result.model).count) + } +} + +// MARK: - The scheduled heal + +/// The store's half — the six-step engine, a notice, and no consent gate anywhere (re-ruled +/// 2026-07-29: the user-gated Repair banner retired). +@MainActor +@Suite("Duplicate ids ▸ the scheduled heal") +struct DuplicateIdentityHealTests { + + private func store() throws -> (WriterFixture, BoardStore) { + let fixture = try makeBoard() + let original = try fixture.item("\(Ident.lane1)/\(Dup.lower)", Item.rich(order: "1024", title: "Fix login")) + let copy = try fixture.item("\(Ident.lane2)/\(Dup.lower)", Item.rich(order: "1024", title: "Fix login")) + try setBirth(original, date(0)) + try setBirth(copy, date(1000)) + return (fixture, try BoardStore(rootURL: fixture.root)) + } + + @Test("It remints the withheld occurrence and announces the repair") + func remintsAndAnnounces() throws { + let (fixture, store) = try store() + defer { fixture.tearDown() } + let brackets = BracketLog() + brackets.attach(to: store) + #expect(store.duplicateIdentities.count == 1) + + store.remintDuplicateIdentities() + + #expect(!fixture.exists("\(Ident.lane2)/\(Dup.lower)")) + #expect(store.banners.losses.map(\.message) == ["Repaired duplicate id — 'Fix login'"]) + #expect(store.banners.oneShots.isEmpty, "nothing failed") + #expect(brackets.begins == 1, "one bracket — one app-mediated reload, one commit") + } + + /// The whole point of the re-ruling: the withheld window is **one heal cycle**, not a standing + /// condition. After the heal, the reload renders both cards under distinct ids. + @Test("The reload after the heal renders both cards and finds no defect") + func theWindowClosesAfterOneCycle() async throws { + let (fixture, store) = try store() + defer { fixture.tearDown() } + + store.remintDuplicateIdentities() + store.handleWatcherEvent(.treeChanged(.appMediated)) + await store.awaitQuiescence() + + #expect(store.duplicateIdentities.isEmpty) + #expect(store.snapshot.lanes.first { $0.id.rawValue == Ident.lane1 }?.cards.count == 1) + #expect(store.snapshot.lanes.first { $0.id.rawValue == Ident.lane2 }?.cards.count == 1) + #expect(Set(renderedIdentities(store.snapshot)).count == renderedIdentities(store.snapshot).count) + } + + @Test("A reload fires it") + func aReloadFiresIt() async throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let original = try fixture.item("\(Ident.lane1)/\(Dup.lower)", Item.rich(order: "1024", title: "Fix login")) + let store = try BoardStore(rootURL: fixture.root) + let copy = try fixture.item("\(Ident.lane2)/\(Dup.lower)", Item.rich(order: "1024", title: "Fix login")) + try setBirth(original, date(0)) + try setBirth(copy, date(1000)) + + store.handleWatcherEvent(.treeChanged(.foreign)) + await store.awaitQuiescence() + + #expect(!fixture.exists("\(Ident.lane2)/\(Dup.lower)")) + #expect(store.banners.losses.count == 1) + } + + /// **Defer, never abandon**: a read-only board writes nothing *and remembers nothing*, so the + /// reload that lifts the lock is the reload that heals. + @Test("A read-only board defers it") + func lockDefersIt() throws { + let (fixture, store) = try store() + defer { fixture.tearDown() } + store.enterUnwritableLock(.permissionDenied) + + store.remintDuplicateIdentities() + + #expect(fixture.exists("\(Ident.lane2)/\(Dup.lower)/index.md")) + #expect(store.banners.losses.isEmpty) + #expect(store.heals.memo(for: .duplicateIdentity) == nil, "deferred, not remembered") + } + + /// **A repeat says nothing more**, and it is the disk re-verify rather than the memo that makes it + /// so: the memo *clears on success* (that is load-bearing — see below), so a second call against a + /// defect list the reload has not replaced yet does open a bracket. What it must not do is rename + /// anything again or claim a second repair, and the Writer's re-verify is what guarantees both. + @Test("A repeat attempt on already-repaired work says nothing more") + func repeatingIsHarmless() throws { + let (fixture, store) = try store() + defer { fixture.tearDown() } + + store.remintDuplicateIdentities() + let after = try fixture.entryNames(Ident.lane2) + store.remintDuplicateIdentities() + + #expect(try fixture.entryNames(Ident.lane2) == after, "nothing renamed a second time") + #expect(store.banners.losses.count == 1, "and nothing claimed twice") + #expect(store.banners.oneShots.isEmpty) + } + + /// Success clears the memo, which is what lets a *later* duplicate — the same picture restored by + /// a foreign undo, a second hand copy — be healed again immediately. + @Test("Success clears the memo") + func successClearsTheMemo() throws { + let (fixture, store) = try store() + defer { fixture.tearDown() } + + store.remintDuplicateIdentities() + + #expect(store.heals.memo(for: .duplicateIdentity) == nil) + } + + /// The no-op heal: the duplicate resolved itself between the load and the write. Nothing moves, + /// nothing is said, nothing fails. + @Test("A duplicate that vanished before the write is a silent no-op") + func vanishedDuplicateIsASilentNoOp() throws { + let (fixture, store) = try store() + defer { fixture.tearDown() } + #expect(store.duplicateIdentities.count == 1) + try FileManager.default.removeItem(at: fixture.url("\(Ident.lane2)/\(Dup.lower)")) + + store.remintDuplicateIdentities() + + #expect(store.banners.losses.isEmpty, "nothing was claimed to have been repaired") + #expect(store.banners.oneShots.isEmpty) + } + + /// **Not undoable** (13-native-undo.md, re-ruled 2026-07-29): heals are not gestures, so nothing + /// enters the stack — and undoing a remint would recreate the duplicate it exists to remove. + @Test("It registers no undo step") + func registersNoUndoStep() throws { + let (fixture, store) = try store() + defer { fixture.tearDown() } + let steps = StepLog() + store.history = steps + + store.remintDuplicateIdentities() + + #expect(steps.registered.isEmpty) + } + + /// A hand-copied lane's worth of collisions arrive together and heal together, in one bracket, + /// under one folded notice. + @Test("Several duplicates heal in one bracket under one folded notice") + func severalHealTogether() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + for (index, id) in [Dup.lower, Dup.other, Ident.card3].enumerated() { + let original = try fixture.item("\(Ident.lane1)/\(id)", Item.rich(order: "\(1024 * (index + 1))", title: "Card \(index)")) + let copy = try fixture.item("\(Ident.lane2)/\(id)", Item.rich(order: "\(1024 * (index + 1))", title: "Card \(index)")) + try setBirth(original, date(0)) + try setBirth(copy, date(1000)) + } + let store = try BoardStore(rootURL: fixture.root) + let brackets = BracketLog() + brackets.attach(to: store) + #expect(store.duplicateIdentities.count == 3) + + store.remintDuplicateIdentities() + + #expect(brackets.begins == 1) + #expect(store.banners.losses.map(\.message) == ["Repaired 3 duplicate ids"]) + let result = try BoardLoader.load(boardRoot: fixture.root) + #expect(result.defects.isEmpty) + #expect(result.model.lanes.first { $0.id.rawValue == Ident.lane2 }?.cards.count == 3) + } + + /// Case twins reach no heal at all: the store's defect stream never carries one, so the engine + /// rests and the folders stay exactly where they are. + @Test("A case twin schedules nothing") + func caseTwinSchedulesNothing() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + try fixture.item("\(Ident.lane1)/\(Dup.upper)", Item.rich(order: "1024", title: "Fix login")) + try fixture.item("\(Ident.lane2)/\(Dup.lower)", Item.rich(order: "1024", title: "Fix login")) + let store = try BoardStore(rootURL: fixture.root) + let brackets = BracketLog() + brackets.attach(to: store) + + store.remintDuplicateIdentities() + + #expect(brackets.begins == 0, "no work, no bracket") + #expect(store.banners.losses.isEmpty) + #expect(fixture.exists("\(Ident.lane1)/\(Dup.upper)/index.md")) + } + + /// **The straddle healed end to end**: the ghost in the trash is the withheld occurrence, so it is + /// the ghost that gets reminted — the restored card keeps the identity it was restored with, and + /// the trash ends up holding an ordinary unrelated entry. + @Test("The ghost is the one reminted, never the restored card") + func theGhostIsReminted() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let live = try fixture.item("\(Ident.lane1)/\(Dup.lower)", Item.rich(order: "1024", title: "Fix login")) + let ghost = try fixture.item(".trash/\(Dup.lower)", Item.rich(order: "1024", title: "Fix login")) + try setBirth(live, date(1000)) + try setBirth(ghost, date(0)) + let store = try BoardStore(rootURL: fixture.root) + + store.remintDuplicateIdentities() + + #expect(fixture.exists("\(Ident.lane1)/\(Dup.lower)/index.md"), "the restored card never moved") + #expect(!fixture.exists(".trash/\(Dup.lower)")) + #expect(store.banners.losses.map(\.message) == ["Repaired duplicate id — 'Fix login'"]) + + let result = try BoardLoader.load(boardRoot: fixture.root) + #expect(result.defects.isEmpty) + #expect(result.model.lanes.first { $0.id.rawValue == Ident.lane1 }?.cards.count == 1) + #expect(result.model.trash.count == 1) + #expect(Set(renderedIdentities(result.model)).count == renderedIdentities(result.model).count) + } + + /// It rides `runScheduledHeals()` like every other scheduled heal — the reload tail and board open. + @Test("runScheduledHeals includes it") + func runScheduledHealsIncludesIt() throws { + let (fixture, store) = try store() + defer { fixture.tearDown() } + + store.runScheduledHeals() + + #expect(!fixture.exists("\(Ident.lane2)/\(Dup.lower)")) + #expect(store.banners.losses.map(\.message) == ["Repaired duplicate id — 'Fix login'"]) + } +} + +// MARK: - Phrasing + +@Suite("Duplicate ids ▸ phrasing") +struct DuplicateIdentityPhrasingTests { + + /// The design's own sentence, verbatim. + @Test("One remint names the item") + func oneRemintNamesTheItem() { + #expect(BannerCenter.remintedDuplicateIDsMessage(for: ["Fix login"]) + == "Repaired duplicate id — 'Fix login'") + } + + @Test("An untitled item takes the untitled rendering") + func untitled() { + #expect(BannerCenter.remintedDuplicateIDsMessage(for: [nil]) + == "Repaired duplicate id — an untitled item") + } + + @Test("Several fold to a count") + func severalFold() { + #expect(BannerCenter.remintedDuplicateIDsMessage(for: ["Fix login", nil, "Ship it"]) + == "Repaired 3 duplicate ids") + } + + @Test("Nothing reminted says nothing") + func nothingSaysNothing() { + #expect(BannerCenter.remintedDuplicateIDsMessage(for: []) == nil) + } + + /// It rides the **loss-row** class: warning tone, user-dismissed, never expiring — the + /// relocation's class, because it is the same kind of event. Emphatically not a condition banner + /// with a button: that is the surface the 2026-07-29 re-ruling retired. + @Test("It rides the loss-row class, with no condition and no button") + @MainActor + func ridesTheLossClass() { + let banners = BannerCenter() + banners.postRemintedDuplicateIDs(["Fix login"]) + #expect(banners.losses.count == 1) + #expect(banners.oneShots.isEmpty) + #expect(banners.signposts.isEmpty) + } + + /// The failure's mirror, in the one-shot vocabulary the Writer's errors reach the strip through. + @Test("A failed remint says so") + func failureSaysSo() { + let error = BoardWriteError( + operation: .repairDuplicateID(title: "Fix login"), + path: "/b/lane/card", + reason: .io(message: "permission denied") + ) + #expect(BannerCenter.headline(for: error) + == "Couldn't repair the duplicate id of 'Fix login' — permission denied") + } + + @Test("An untitled failure stays graceful") + func untitledFailure() { + let error = BoardWriteError( + operation: .repairDuplicateID(title: nil), + path: "/b/lane/card", + reason: .io(message: "disk full") + ) + #expect(BannerCenter.headline(for: error) == "Couldn't repair a duplicate id — disk full") + } + + /// The Repair verb survives the re-ruling as the operation's own word — 06-history-undo.md keeps + /// it as the heal commit's name. + @Test("The operation describes itself with the Repair verb") + func operationDescribesItself() { + #expect(WriteOperation.repairDuplicateID(title: "Fix login").description + == "repair the duplicate id of 'Fix login'") + } + + /// The remint never opens an `index.md`, so there is no document to enrich its title from — it + /// arrives already filled in from the load. + @Test("withTitle leaves it alone") + func withTitleIsIdentity() { + #expect(WriteOperation.repairDuplicateID(title: "Fix login").withTitle("Something else") + == .repairDuplicateID(title: "Fix login")) + } +} diff --git a/KanbanTests/FixtureBoardTests.swift b/KanbanTests/FixtureBoardTests.swift index f03002a..266112b 100644 --- a/KanbanTests/FixtureBoardTests.swift +++ b/KanbanTests/FixtureBoardTests.swift @@ -294,7 +294,11 @@ struct FixtureDuplicateOrderTieBreakTests { let laneA = "10000000-0000-4000-8000-000000000001" let laneAAA = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" let laneBBB = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb" - let cardX = "10000000-0000-4000-8000-000000000001" + // Distinct from `laneA` on purpose: a lane and one of its own cards sharing a UUID is a + // duplicate id board-wide, which the loader's dedupe withholds (01-storage-format.md + // § Fractal layout ▸ Rules). It sorts before `cardY` either way, which is all this fixture + // ever needed of it. + let cardX = "10000000-0000-4000-8000-00000000000a" let cardY = "20000000-0000-4000-8000-000000000002" let cardZ = "30000000-0000-4000-8000-000000000003"