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)
}
}