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) 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 /// 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 /// 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 /// 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 // 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. // the thing sitting there) because the app has just demonstrated it cannot.
"Couldn't move '\(name)' aside — Lanework needs that name" "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): case let .toggleTask(title):
// The user's word for it, not the file's: they ticked a box. The card is named where // 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 // 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" 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 /// 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 /// 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). /// 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 } } 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 /// 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 /// 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 /// 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 // MARK: - The agent guide
/// Brings the board root's `CLAUDE.md` up to the current guide version, or leaves it exactly as /// 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 /// 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. /// 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 /// 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 /// `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. /// 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() displaceClaimedNames()
relocateLooseCardFiles() relocateLooseCardFiles()
migrateLegacyTombstones() migrateLegacyTombstones()
remintDuplicateIdentities()
refreshAgentGuide() refreshAgentGuide()
} }
+10
View File
@@ -162,6 +162,14 @@ public final class HealScheduler {
case migratedTombstones(cards: [String?], lanes: [String?]) case migratedTombstones(cards: [String?], lanes: [String?])
/// Loss row, warning tone: a claimed name's squatter was moved aside, named old and new. /// Loss row, warning tone: a claimed name's squatter was moved aside, named old and new.
case displacedClaimedNames([BannerCenter.Displacement]) 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) { private static func post(_ notice: HealNotice, to banners: BannerCenter) {
@@ -174,6 +182,8 @@ public final class HealScheduler {
banners.postMigratedTombstones(cards: cards, lanes: lanes) banners.postMigratedTombstones(cards: cards, lanes: lanes)
case let .displacedClaimedNames(displacements): case let .displacedClaimedNames(displacements):
banners.postDisplacedClaimedNames(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 // 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) try checkIsReadableDirectory(boardRoot)
let boardIndexURL = boardRoot.appendingPathComponent(indexFileName) let boardIndexURL = boardRoot.appendingPathComponent(indexFileName)
@@ -184,7 +187,11 @@ public enum BoardLoader: Sendable {
warn(.boardLevelDeletedIgnored) 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) { for laneURL in try directoryCandidates(in: boardRoot) {
let laneName = laneURL.lastPathComponent let laneName = laneURL.lastPathComponent
// The app-claimed board-root names are not strays and must not warn as such. Hidden // 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") logger.info("\(laneName, privacy: .public): legacy 'deleted' key — lane to be returned live with the key removed")
} }
lanes.append(Lane( walkedLanes.append(WalkedLane(
id: ItemID(rawValue: laneName), name: laneName,
schema: laneSchema, 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, order: laneOrder,
width: laneDocument.width, document: laneDocument,
cards: Ranks.sortedForDisplay(cards, order: \.order, name: { $0.id.rawValue }), cards: Ranks.sortedForDisplay(cards, order: \.order, name: { $0.id.rawValue })
document: laneDocument
)) ))
} }
@@ -301,6 +299,40 @@ public enum BoardLoader: Sendable {
trash.append(entry) 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( let model = BoardModel(
rootURL: boardRoot, rootURL: boardRoot,
schema: boardSchema, schema: boardSchema,
@@ -313,8 +345,8 @@ public enum BoardLoader: Sendable {
icon: boardDocument.icon, icon: boardDocument.icon,
iconColor: boardDocument.iconColor, iconColor: boardDocument.iconColor,
template: boardDocument.value(for: templateKey), template: boardDocument.value(for: templateKey),
lanes: Ranks.sortedForDisplay(lanes, order: \.order, name: { $0.id.rawValue }), lanes: orderedLanes.compactMap { $0.rendered(withholding: withheld) },
trash: Ranks.sortedForDisplay(trash, order: \.order, name: { $0.id.rawValue }), trash: orderedTrash.filter { !withheld.contains(trashFolderName + "/" + $0.id.rawValue) },
document: boardDocument document: boardDocument
) )
@@ -322,10 +354,172 @@ public enum BoardLoader: Sendable {
model: model, model: model,
warnings: warnings, warnings: warnings,
defects: defects, 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 /// 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 /// holding its own `index.md` (01-storage-format.md § Deletion: "UUID-shaped children with
/// their own `index.md` lane else card"). /// their own `index.md` lane else card").
@@ -739,6 +933,17 @@ public struct LoadResult: Sendable {
public var claimedNameSquatters: [ClaimedNameSquatter] { public var claimedNameSquatters: [ClaimedNameSquatter] {
defects.compactMap { if case let .claimedNameSquatted(work) = $0 { work } else { nil } } 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` /// 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. /// (01-storage-format.md § Deletion) ignored, never tombstones the board.
case boardLevelDeletedIgnored 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 { public var description: String {
switch self { switch self {
case let .missingIndex(path): 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" "\(path): folder name is not UUID-shaped, ignored as a stray"
case .boardLevelDeletedIgnored: case .boardLevelDeletedIgnored:
"index.md: board-level 'deleted' key is meaningless, ignored" "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 /// `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. /// verbatim set would miss exactly that collision and let a duplicate UUID into the board.
private static func identities(inBoard boardRoot: URL) -> Set<String> { 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) { for lane in childCandidates(of: boardRoot) {
identities.insert(IntegrityRules.canonicalIdentity(lane.lastPathComponent)) identities.append(IntegrityRules.canonicalIdentity(lane.lastPathComponent))
for card in childCandidates(of: lane) { 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 // **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 // 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. // hopeful: an import that would have produced the twin was reminted before it landed.
for card in childCandidates(of: trashFolder(inBoard: boardRoot)) { for card in childCandidates(of: trashFolder(inBoard: boardRoot)) {
identities.insert(IntegrityRules.canonicalIdentity(card.lastPathComponent)) identities.append(IntegrityRules.canonicalIdentity(card.lastPathComponent))
} }
return identities return identities
} }
@@ -2198,6 +2208,71 @@ public enum BoardWriter: Sendable {
return freed 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 /// 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 /// attachment surfaces are flat: top-level files only"): the top-level *files* of
/// `attachments/`, subfolders and hidden files excluded, symlinks excluded, sorted /// `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. /// owns that file end to end and has one outcome the user could care about.
case displaceClaimedName(name: String) 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). /// 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 /// 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. /// case is immutable once a caller has it in hand there is nothing to "forget" later.
public func withTitle(_ title: String?) -> WriteOperation { public func withTitle(_ title: String?) -> WriteOperation {
switch self { 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, case .createBoard, .createLane, .createCard, .importAttachment, .listAttachments,
.removeAttachment, .renumberChildren, .relocateLooseFile, .agentGuide, .removeAttachment, .renumberChildren, .relocateLooseFile, .agentGuide,
.displaceClaimedName: .displaceClaimedName, .repairDuplicateID:
self self
case .move: .move(title: title) case .move: .move(title: title)
case .reorder: .reorder(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 let .relocateLooseFile(filename): "relocate loose file '\(filename)'"
case .agentGuide: "update the agent guide" case .agentGuide: "update the agent guide"
case let .displaceClaimedName(name): "move a stray '\(name)' aside" 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 .toggleTask(title): Self.phrase("toggle a checkbox in", title)
case let .editBody(title): Self.phrase("save the body of", title) case let .editBody(title): Self.phrase("save the body of", title)
case let .rawSource(title): Self.phrase("apply source changes to", 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) case legacyTombstone(LegacyTombstone)
/// A claimed board-root name held by the wrong kind of node (ruled 2026-07-29). /// A claimed board-root name held by the wrong kind of node (ruled 2026-07-29).
case claimedNameSquatted(ClaimedNameSquatter) 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 /// The scheduled-heal classes, which are also the engine's memo keys and its
/// banner-posture rows (`HealScheduler`). /// banner-posture rows (`HealScheduler`).
@@ -463,6 +466,7 @@ public enum IntegrityRules: Sendable {
case looseCardFiles case looseCardFiles
case legacyTombstone case legacyTombstone
case claimedNameSquatted case claimedNameSquatted
case duplicateIdentity
case staleAgentGuide case staleAgentGuide
} }
@@ -471,6 +475,7 @@ public enum IntegrityRules: Sendable {
case .looseCardFiles: .looseCardFiles case .looseCardFiles: .looseCardFiles
case .legacyTombstone: .legacyTombstone case .legacyTombstone: .legacyTombstone
case .claimedNameSquatted: .claimedNameSquatted 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 // of squatter is a new defect, and a heal that failed on one has no claim to have
// failed on the other. // failed on the other.
["claimed:\(work.name):\(work.found.rawValue)"] ["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 // MARK: - The defect payloads
@@ -580,3 +819,63 @@ public struct ClaimedNameSquatter: Sendable, Equatable {
self.expected = expected 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
}
}
File diff suppressed because it is too large Load Diff
+5 -1
View File
@@ -294,7 +294,11 @@ struct FixtureDuplicateOrderTieBreakTests {
let laneA = "10000000-0000-4000-8000-000000000001" let laneA = "10000000-0000-4000-8000-000000000001"
let laneAAA = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" let laneAAA = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"
let laneBBB = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb" 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 cardY = "20000000-0000-4000-8000-000000000002"
let cardZ = "30000000-0000-4000-8000-000000000003" let cardZ = "30000000-0000-4000-8000-000000000003"