Dedupe duplicate ids and heal them silently

The crash-class gap the integrity design pass found (DESIGN/01 -
Fractal layout rules; 02 - Live-reload resilience): the loader had no
board-wide dedupe at all, so two hand-copied folders sharing a UUID put
two equal ItemIDs into one snapshot - which SwiftUI's ForEach does not
tolerate. Built to the day's re-rulings, both landing mid-flight: the
user-gated Repair banner retired (176c852 - the heal runs silently) and
the container boundary became the first tie-break (f153e79 - the
visible card never loses to its own trash ghost).

IntegrityRules.dedupe (pure, occurrence list in, verdict out): group by
canonical identity, collapse case-spelled twins first - spellings with
a live occurrence outrank trash-only spellings, then canonical
lowercase, then lexicographically first; losers are silent strays
(LoadWarning.caseTwinIgnored - spelling artifacts, never reminted) -
then earlier-occurrence-wins across the surviving spelling's folders on
a four-rung ladder: live-before-trashed, git path history rank, FS
birth date (nil is no comparison, never .distantPast), traversal
order. Occurrences are exactly the identity-bearing folders: lanes,
cards, .trash entries - a UUID-shaped folder under a card is content.

BoardLoader walks lanes as WalkedLane and builds Lane values only on
the far side of the verdict, so a withheld card can never reach a
snapshot; a name-only gate keeps the healthy-board cost at one
dictionary pass, no disk reads. Withheld subtrees are still walked - a
hand-copied lane's nested cards are their own withheld occurrences,
reminted at the finest grain like the import boundary would have. A
withheld trash entry's trashKinds reading leaves with it. The git rung
is a seam (BoardLoader.IdentityHistoryRanker, one closure keyed by
root-relative path) because base links no git machinery - base injects
nothing and falls through; pro-m1 owns the ranker (card annotated).

The heal: Defect.duplicateIdentity (signature duplicate:<path>:<id>)
rides HealScheduler as the fourth scheduled heal, ordered last among
the content heals because a remint renames folders and would stale the
paths the same load handed the relocation and migration.
BoardWriter.remintDuplicateIdentity re-verifies twice at write time -
the folder still carries the losing identity AND something else still
does (the vanished-duplicate race no-ops from either side) - then
renames to a fresh v4 minted against the whole board's identity bag.
A rename and nothing else: no index.md opened, no modified stamp, no
modified-by clear; the receipt is heal-marked (pro-m1's committer
splits it out, named by 06's kept Repair verb); no undo step - heals
are not gestures. The notice is the design's own sentence ("Repaired
duplicate id - 'Fix login'"; several fold to a count), a loss row on
the relocation's reasoning; WriteOperation.repairDuplicateID carries
the failure mirror.

Fixture repair rode along: duplicate-order-tie-break.kanban had a lane
and its own card sharing a UUID - a genuine duplicate the new pass
correctly withholds; the folder rename landed in feae6d0, the matching
test constant lands here.

66 tests added (DuplicateIdentityTests: the ladder rung by rung, the
straddles, withheld-lane subtrees, remint idempotence and races, the
one-heal-cycle window, all phrasing). 1804 green on both schemes.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
2026-07-29 18:19:52 -04:00
parent 68fa503250
commit 0463540aea
8 changed files with 1979 additions and 24 deletions
+49
View File
@@ -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).
+82
View File
@@ -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()
}
+10
View File
@@ -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)
}
}
+238 -18
View File
@@ -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<String> = []
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<String>) -> 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"
}
}
}
+98 -5
View File
@@ -672,11 +672,21 @@ public enum BoardWriter: Sendable {
/// `55555555-` and a resident `55555555-` spelled uppercase are **one** identity, and a
/// verbatim set would miss exactly that collision and let a duplicate UUID into the board.
private static func identities(inBoard boardRoot: URL) -> Set<String> {
var identities: Set<String> = []
Set(identityOccurrences(inBoard: boardRoot))
}
/// The same walk as a **bag rather than a set** every identity-bearing folder's canonical name,
/// duplicates included, which is what lets the duplicate-id remint ask "does anything else still
/// carry this identity" instead of merely "is it present" (`remintDuplicateIdentity`).
///
/// The two exist as one walk deliberately: a re-verify that read the board differently from the
/// collision probe would be a second definition of "what this board contains".
private static func identityOccurrences(inBoard boardRoot: URL) -> [String] {
var identities: [String] = []
for lane in childCandidates(of: boardRoot) {
identities.insert(IntegrityRules.canonicalIdentity(lane.lastPathComponent))
identities.append(IntegrityRules.canonicalIdentity(lane.lastPathComponent))
for card in childCandidates(of: lane) {
identities.insert(IntegrityRules.canonicalIdentity(card.lastPathComponent))
identities.append(IntegrityRules.canonicalIdentity(card.lastPathComponent))
}
}
// **The trash counts.** Board-wide uniqueness spans both containers (01-storage-format.md
@@ -687,7 +697,7 @@ public enum BoardWriter: Sendable {
// This is also what makes `deleteCardToTrash`'s "collision is impossible" true rather than
// hopeful: an import that would have produced the twin was reminted before it landed.
for card in childCandidates(of: trashFolder(inBoard: boardRoot)) {
identities.insert(IntegrityRules.canonicalIdentity(card.lastPathComponent))
identities.append(IntegrityRules.canonicalIdentity(card.lastPathComponent))
}
return identities
}
@@ -2198,6 +2208,71 @@ public enum BoardWriter: Sendable {
return freed
}
/// **Remints a withheld duplicate identity** the write half of the duplicate-id heal
/// (01-storage-format.md § Fractal layout Rules, re-ruled 2026-07-29: "the heal gives each
/// withheld occurrence the fresh identity the import boundary would have minted").
///
/// Copy semantics applied at detection: a hand copy in Finder *was* a copy, so it gets what a
/// copy gets a fresh lowercase v4 folder name, minted away from every identity in the board.
/// After it lands, the folder renders as an ordinary item.
///
/// **A rename and nothing else.** The folder keeps its parent; its `index.md`, its frontmatter,
/// its children, its attachments and its strays are never opened. That is not a carve-out but the
/// existing write discipline answering: a heal that only renames stamps nothing no `modified`,
/// no cleared `modified-by` because this is an identity repair, not an edit (§ Validation and
/// healing).
///
/// **It re-verifies against disk, twice over** (§ Validation and healing: "every scheduled heal
/// re-verifies its defect against disk at write time and no-ops when it is gone"), and each check
/// answers `nil` success, never an error:
///
/// 1. The folder is still there, still a directory, and still spelled with the identity the
/// detection named. A folder already reminted (this heal running twice, another device's heal
/// arriving first) fails here.
/// 2. **Something else still carries that identity.** The winner may have been hand-deleted or
/// moved out since the load, in which case this folder is no longer a duplicate of anything and
/// reminting it would change an identity for no reason at all the vanished-duplicate race,
/// read from the surviving side.
///
/// **Heal-marked**, because the app started it on its own: the receipt is what splits the remint
/// into its own commit on git boards, named for the Repair verb (06-history-undo.md Commit
/// messages). Undo never sees it heals are not gestures (13-native-undo.md).
///
/// - Parameter duplicate: the withheld occurrence, `path` relative to `boardRoot` so the write
/// lands wherever the board lives *now*.
/// - Returns: the fresh identity, or `nil` when the defect was already gone.
@discardableResult
public static func remintDuplicateIdentity(
_ duplicate: DuplicateIdentity,
inBoard boardRoot: URL
) throws(BoardWriteError) -> ItemID? {
let operation = WriteOperation.repairDuplicateID(title: duplicate.title)
let folder = boardRoot.appendingPathComponent(duplicate.path, isDirectory: true)
// 1. Still there, still a folder, still carrying the identity that lost.
guard IntegrityRules.node(at: folder) == .directory,
IntegrityRules.canonicalIdentity(folder.lastPathComponent) == duplicate.identity
else {
return nil
}
// 2. Still a duplicate *of something*. One occurrence is this folder itself, so the identity
// has to appear at least twice for the defect to still stand.
let occurrences = identityOccurrences(inBoard: boardRoot)
guard occurrences.filter({ $0 == duplicate.identity }).count > 1 else { return nil }
// Minted away from every identity in the board, not merely from this parent's children: the
// point of the remint is board-wide uniqueness, and a fresh name colliding with a folder two
// lanes over would trade one duplicate for another.
let fresh = freshUUIDName(in: folder.deletingLastPathComponent(), avoiding: Set(occurrences))
try renameFolder(folder, toSiblingNamed: fresh, operation: operation)
// The move pair is `renameFolder`'s; the heal mark is this call's, because the remint is
// app-initiated work whose paths commit separately (06-history-undo.md Commit messages).
EchoLedger.current?.markHeal(
at: folder.deletingLastPathComponent().appendingPathComponent(fresh, isDirectory: true)
)
return ItemID(rawValue: fresh)
}
/// The card's flat attachment listing (01-storage-format.md § Attachments, "the app's
/// attachment surfaces are flat: top-level files only"): the top-level *files* of
/// `attachments/`, subfolders and hidden files excluded, symlinks excluded, sorted
@@ -2588,6 +2663,20 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible {
/// owns that file end to end and has one outcome the user could care about.
case displaceClaimedName(name: String)
/// A withheld duplicate id being reminted the duplicate-id heal's write (01-storage-format.md
/// § Fractal layout Rules, re-ruled 2026-07-29 from its former banner gate).
///
/// **Named for the Repair verb**, which is 06-history-undo.md's vocabulary for exactly this act
/// ("Repair duplicate of 'Fix login'" the heal commit's own name) and survived the re-ruling
/// intact: what changed is who starts it, not what it is called.
///
/// Its own case on `.relocateLooseFile`'s and `.displaceClaimedName`'s reasoning: this is work the
/// *app* started on its own, on a folder the user copied in Finder without knowing it would
/// collide, and a banner saying the app "couldn't move the item" would name a gesture that never
/// happened. `title` is the withheld item's as the load found it the name the user would
/// recognize, and the one the successful notice uses.
case repairDuplicateID(title: String?)
/// A Preview task-list checkbox being ticked or unticked (05-card-window.md Preview).
///
/// Its own case rather than a fold into `.rename`'s or `.style`'s neighbourhood, on the
@@ -2634,9 +2723,12 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible {
/// case is immutable once a caller has it in hand there is nothing to "forget" later.
public func withTitle(_ title: String?) -> WriteOperation {
switch self {
// `.repairDuplicateID` is identity here even though it carries a title: the remint never
// opens an `index.md` it is a rename so there is no `readDocument` to enrich from, and
// its title arrives already filled in from the load that detected the duplicate.
case .createBoard, .createLane, .createCard, .importAttachment, .listAttachments,
.removeAttachment, .renumberChildren, .relocateLooseFile, .agentGuide,
.displaceClaimedName:
.displaceClaimedName, .repairDuplicateID:
self
case .move: .move(title: title)
case .reorder: .reorder(title: title)
@@ -2684,6 +2776,7 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible {
case let .relocateLooseFile(filename): "relocate loose file '\(filename)'"
case .agentGuide: "update the agent guide"
case let .displaceClaimedName(name): "move a stray '\(name)' aside"
case let .repairDuplicateID(title): Self.phrase("repair the duplicate id of", title)
case let .toggleTask(title): Self.phrase("toggle a checkbox in", title)
case let .editBody(title): Self.phrase("save the body of", title)
case let .rawSource(title): Self.phrase("apply source changes to", title)
+299
View File
@@ -450,6 +450,9 @@ public enum IntegrityRules: Sendable {
case legacyTombstone(LegacyTombstone)
/// A claimed board-root name held by the wrong kind of node (ruled 2026-07-29).
case claimedNameSquatted(ClaimedNameSquatter)
/// A later occurrence of an identity the board already carries withheld from the snapshot
/// and reminted by the scheduled heal (re-ruled 2026-07-29 the silent remint).
case duplicateIdentity(DuplicateIdentity)
/// The scheduled-heal classes, which are also the engine's memo keys and its
/// banner-posture rows (`HealScheduler`).
@@ -463,6 +466,7 @@ public enum IntegrityRules: Sendable {
case looseCardFiles
case legacyTombstone
case claimedNameSquatted
case duplicateIdentity
case staleAgentGuide
}
@@ -471,6 +475,7 @@ public enum IntegrityRules: Sendable {
case .looseCardFiles: .looseCardFiles
case .legacyTombstone: .legacyTombstone
case .claimedNameSquatted: .claimedNameSquatted
case .duplicateIdentity: .duplicateIdentity
}
}
@@ -492,9 +497,243 @@ public enum IntegrityRules: Sendable {
// of squatter is a new defect, and a heal that failed on one has no claim to have
// failed on the other.
["claimed:\(work.name):\(work.found.rawValue)"]
case let .duplicateIdentity(work):
// The *identity* is part of the picture beside the path: the same folder losing a
// different collision (its winner reminted, a third copy landing) is new work, and a
// heal that failed on one has no claim to have failed on the other.
["duplicate:\(work.path):\(work.identity)"]
}
}
}
// MARK: - The board-wide identity dedupe
/// One identity-bearing folder the walk met the **input** to `dedupe(_:)`, and everything the
/// rule needs about an occurrence (01-storage-format.md § Fractal layout Rules, "Duplicate ids
/// within a board are never tolerated").
///
/// Occurrences are exactly the schema's identity-bearing folders: depth-1 lanes, depth-2 cards,
/// and `.trash/`'s flat entries. Nothing deeper is one "level is position", and a UUID-shaped
/// folder under a card is content, not an identity.
public struct IdentityOccurrence: Sendable, Equatable {
/// **Which side of the container boundary an occurrence sits on** the board or `.trash/`
/// (02-architecture.md Live-reload resilience already makes "container side" vocabulary:
/// re-resolution matches UUID *and* container side).
///
/// It is a field rather than something derived from `path` here because `IntegrityRules` takes
/// the facts it needs as parameters rather than parsing paths or reading disk the loader
/// knows which container it walked, and telling the rule beats re-deriving it from a string.
public enum Container: Sendable, Equatable {
/// A lane, or a card under a lane something the board renders.
case live
/// A flat `.trash/` entry a card or a trashed lane.
case trashed
}
/// The folder's path **relative to the board root** `"<lane>"`, `"<lane>/<card>"`,
/// `".trash/<card>"`.
///
/// A path rather than an `ItemID` pair, and that is forced rather than chosen: the whole
/// subject here is *two folders carrying one id*, so an id-keyed payload would be ambiguous
/// about which of them it names. The path is the only unambiguous key a duplicate has, and it
/// stays relative for `LooseCardFiles`' reason the write joins it onto the store's
/// *current* root, so a board renamed mid-session heals at its new location.
public let path: String
/// The folder name exactly as it is spelled on disk. Case matters here and only here: the
/// case-twin collapse compares spellings, everything else compares identities.
public let name: String
/// Which container this occurrence was walked in **the first tie-break**, ahead of history
/// and age alike (01-storage-format.md § Fractal layout Rules, stated 2026-07-29).
public let container: Container
/// The item's title as written, `nil` for an untitled one "Untitled" is a rendering, never
/// a value (03-board-ui.md § Card face), so the phrasing layer decides what to call it.
public let title: String?
/// The filesystem birth date (`.creationDateKey`), `nil` when it cannot be read the
/// **second** rung of the earlier-occurrence-wins ladder.
public let birth: Date?
/// Where the git path history places this path, `nil` when the board has no history or the
/// path is untracked the **first** rung, injected through `BoardLoader.IdentityHistoryRanker`.
public let historyRank: Int?
public init(
path: String,
name: String,
container: Container,
title: String?,
birth: Date?,
historyRank: Int?
) {
self.path = path
self.name = name
self.container = container
self.title = title
self.birth = birth
self.historyRank = historyRank
}
/// The occurrence's identity its name reduced to a UUID *value*.
public var identity: String { canonicalIdentity(name) }
}
/// What the dedupe decided: the two classes of loser, each in traversal order.
///
/// **The two are deliberately different verdicts**, not one list with a flag: a case twin is
/// *tolerated* (a spelling artifact of the same item logged, preserved, never rendered, never
/// touched) and a content duplicate is *healed* (a copy the app remints). Collapsing them would
/// mean either announcing spelling or reminting content the user never made.
public struct DedupeVerdict: Sendable, Equatable {
/// The later occurrences withheld from the snapshot the heal's work.
public let duplicates: [DuplicateIdentity]
/// The case-spelled twins skipped silently the tolerate tier's work, which is none.
public let caseTwins: [CaseTwin]
}
/// **The board-wide dedupe** one occurrence per identity, decided from an occurrence list and
/// nothing else (01-storage-format.md § Fractal layout Rules, settled; the silent-remint
/// re-ruling of 2026-07-29 changed what happens *after* this, never what it decides).
///
/// A snapshot must never carry two items with equal ids SwiftUI's `ForEach` does not tolerate
/// it so this runs on every load and its answer is subtractive: every group of occurrences
/// sharing one identity keeps exactly one, and every other member is named here.
///
/// ### The two classes, in the order they apply
///
/// 1. **Case-spelled twins collapse first, and silently.** Occurrences of one identity whose
/// name *strings* differ can only differ in case (they are the same hex under
/// `canonicalIdentity`), which makes them spelling artifacts of one item rather than copies:
/// one spelling wins and every other takes the **stray** posture skipped with a pointed log
/// line, preserved verbatim, never rendered, never reminted. Reminting one would *create*
/// duplicate content the user never made.
///
/// **The winning spelling is chosen under the container preference too** (stated 2026-07-29):
/// spellings carried by at least one *live* occurrence are the candidates, and only among those
/// or among all of them when the whole group is trashed does canonical-all-lowercase-else-
/// lexicographically-first decide. Without that filter a live card spelled `AAAA` would lose
/// the spelling contest to its own lowercase ghost in the trash and be *skipped*, which is the
/// straddle case reading the rule backwards: the visible card never loses to its own ghost.
/// 2. **Then earlier-occurrence-wins across what is left**, which all share one spelling and so
/// necessarily sit under different parents a hand copy keeping its UUID. The earliest
/// occurrence renders; every later one is **withheld** and healed.
///
/// A consequence worth naming: an occurrence that is *both* a hand copy whose case was also
/// hand-changed degrades to the silent case-twin posture and is never reminted. That is the
/// spelling-artifacts-stay-silent ruling read literally, and the conservative direction: the
/// board renders one item per id either way, and the app declines to mint identity for a folder
/// whose spelling says "the same item, typed differently".
///
/// ### The ladder
///
/// 0. **The container boundary** (`container`) **the first tie-break, ahead of history and age
/// alike** (01-storage-format.md § Fractal layout Rules, stated 2026-07-29): "when occurrences
/// straddle live and trashed, the **live occurrence keeps the identity** regardless of age".
/// The realistic straddle is a restore done as a *copy* an -drag out of the trash in Finder,
/// an agent that copies instead of moves where the ghost left behind is genuinely the older
/// folder and often the tracked one, so every other rung would withhold the very card the user
/// just restored and render its ghost instead. The heal remints the trashed occurrence. The same
/// preference governs a trashed lane sharing a live lane's UUID.
/// 1. **Git path history** (`historyRank`): both tracked, the path that entered history first
/// wins; one tracked, it outranks the newcomer outright.
/// 2. **Filesystem birth date** (`birth`): the older folder wins. Only consulted when *both*
/// dates are readable and they differ one unreadable date is no comparison at all.
/// 3. **Deterministic traversal order**, which is `occurrences`' own order and therefore the
/// caller's contract: lane `order`, then card `order`, then the folder-name tie-break
/// (`BoardLoader` passes them exactly so).
///
/// Rungs 13 are the *earlier-occurrence-wins* rule; rung 0 is not about age at all, which is why
/// it sits outside and above it.
///
/// Pure, like everything here: the container, the birth dates and the history ranks are read by
/// the loader and arrive as values, so the whole rule is pinned by the suite without a filesystem
/// or a repo in the way.
public static func dedupe(_ occurrences: [IdentityOccurrence]) -> DedupeVerdict {
// Grouped by identity, first-seen order preserved determinism starts here, because a
// dictionary's own iteration order is not one.
var members: [String: [Int]] = [:]
var identities: [String] = []
for (index, occurrence) in occurrences.enumerated() {
let identity = occurrence.identity
if members[identity] == nil { identities.append(identity) }
members[identity, default: []].append(index)
}
var duplicates: [(index: Int, work: DuplicateIdentity)] = []
var caseTwins: [(index: Int, work: CaseTwin)] = []
for identity in identities {
guard let group = members[identity], group.count > 1 else { continue }
// 1. The winning *spelling*, under the container preference first: a spelling some live
// occurrence carries outranks one only trash ghosts carry, and the canonical-else-
// lexicographic rule then decides among the candidates. A wholly trashed group has no
// live candidates and falls through to all of them, unchanged. `identity` is the
// all-lowercase form by construction, so "is the canonical spelling present" is one
// membership test either way.
let spellings = Set(group.map { occurrences[$0].name })
let liveSpellings = Set(
group.lazy.filter { occurrences[$0].container == .live }.map { occurrences[$0].name }
)
let candidates = liveSpellings.isEmpty ? spellings : liveSpellings
let canonical = candidates.contains(identity) ? identity : candidates.sorted()[0]
// 2. Earlier-occurrence-wins among the canonical spelling's occurrences. `sorted` is not
// guaranteed stable, so the traversal index is the comparator's own last rung rather
// than something left to the sort.
let contenders = group.filter { occurrences[$0].name == canonical }
let ranked = contenders.sorted { entered(occurrences[$0], at: $0, before: occurrences[$1], at: $1) }
let winner = occurrences[ranked[0]].path
for index in group where occurrences[index].name != canonical {
caseTwins.append((index, CaseTwin(path: occurrences[index].path, winner: winner)))
}
for index in ranked.dropFirst() {
duplicates.append((index, DuplicateIdentity(
path: occurrences[index].path,
identity: identity,
title: occurrences[index].title,
winner: winner
)))
}
}
// Traversal order across groups too: the notice's subjects and the log's lines read in the
// order the board is laid out, not in the order a dictionary happened to hand out identities.
return DedupeVerdict(
duplicates: duplicates.sorted { $0.index < $1.index }.map(\.work),
caseTwins: caseTwins.sorted { $0.index < $1.index }.map(\.work)
)
}
/// The precedence comparator the four-rung ladder above, and the whole of the winner rule.
///
/// Named for its majority (`entered before ` is earlier-occurrence-wins' own phrasing) even
/// though rung 0 is not about entry order at all: the container preference is stated as *the first
/// tie-break*, so it belongs in the one comparator rather than as a pre-partition the callers of
/// this rule would each have to remember.
private static func entered(
_ lhs: IdentityOccurrence,
at lhsIndex: Int,
before rhs: IdentityOccurrence,
at rhsIndex: Int
) -> Bool {
// 0. The container boundary, ahead of everything: the visible card never loses to its own
// ghost, however much older or better-tracked the ghost is.
if lhs.container != rhs.container { return lhs.container == .live }
switch (lhs.historyRank, rhs.historyRank) {
case let (left?, right?):
// Both tracked: the path that entered history first.
if left != right { return left < right }
case (.some, .none):
// "The path history already tracks outranks the newcomer" read literally.
return true
case (.none, .some):
return false
case (.none, .none):
break
}
if let left = lhs.birth, let right = rhs.birth, left != right { return left < right }
return lhsIndex < rhsIndex
}
}
// MARK: - The defect payloads
@@ -580,3 +819,63 @@ public struct ClaimedNameSquatter: Sendable, Equatable {
self.expected = expected
}
}
/// A **later occurrence** of an identity the board already carries a folder hand-copied in Finder
/// keeping its UUID (01-storage-format.md § Fractal layout Rules: "Duplicate ids within a board are
/// never tolerated Every later occurrence is withheld from rendering preserved verbatim, pointed
/// log line").
///
/// **Withheld, then reminted.** The loader keeps it out of every snapshot, which is what makes the
/// one-item-per-id invariant hold by construction SwiftUI's `ForEach` does not tolerate two equal
/// ids and the scheduled heal then gives it the fresh identity the import boundary would have
/// minted, after which it renders as an ordinary item (re-ruled 2026-07-29: a silent heal, superseding
/// the former user-gated Repair banner "Lanework owns the board and re-mints object UUIDs at will").
///
/// Nothing on disk is lost in the meantime: the folder, its `index.md`, its children and its strays
/// are exactly where they were, and the withheld window is one heal cycle rather than a standing
/// condition (02-architecture.md Live-reload resilience).
public struct DuplicateIdentity: Sendable, Equatable {
/// The withheld folder's path relative to the board root see `IdentityOccurrence.path` for why
/// a duplicate is keyed by path and not by id.
public let path: String
/// The identity both occurrences share, canonically (lowercased). Part of the heal's signature:
/// the same folder losing a *different* collision is new work.
public let identity: String
/// The withheld item's title as written, `nil` for an untitled one what the notice names.
public let title: String?
/// The path of the occurrence that won the log line's other half, and the answer to the only
/// question the log line owes ("withheld in favour of *what*").
public let winner: String
public init(path: String, identity: String, title: String?, winner: String) {
self.path = path
self.identity = identity
self.title = title
self.winner = winner
}
}
/// A folder whose name is a **case-spelled twin** of another occurrence of the same identity one
/// item typed two ways, not two items (01-storage-format.md § Fractal layout Rules: "the canonical
/// all-lowercase spelling wins where present, else the lexicographically first spelling; the loser
/// takes the stray posture skipped with a pointed log line, preserved verbatim, never rendered").
///
/// The winning *spelling* is picked under the container preference first (stated 2026-07-29 see
/// `IntegrityRules.dedupe(_:)`), so a live card is never skipped in favour of its own trashed ghost's
/// spelling. Which side wins is all that changed: a twin is still silent either way.
///
/// **Not a `Defect`, and that is the ruling rather than an omission**: this is the *tolerate* tier
/// there is nothing to do. The twin is a spelling artifact of the item that rendered, so reminting it
/// would create duplicate content the user never made, and announcing it would surface spelling as a
/// problem. It reaches the caller as a `LoadWarning`, where every other tolerated stray lives.
public struct CaseTwin: Sendable, Equatable {
/// The skipped folder's path relative to the board root.
public let path: String
/// The path of the occurrence whose spelling won what the log line names it a twin *of*.
public let winner: String
public init(path: String, winner: String) {
self.path = path
self.winner = winner
}
}