Dedupe duplicate ids and heal them silently
The crash-class gap the integrity design pass found (DESIGN/01 - Fractal layout rules; 02 - Live-reload resilience): the loader had no board-wide dedupe at all, so two hand-copied folders sharing a UUID put two equal ItemIDs into one snapshot - which SwiftUI's ForEach does not tolerate. Built to the day's re-rulings, both landing mid-flight: the user-gated Repair banner retired (176c852- the heal runs silently) and the container boundary became the first tie-break (f153e79- the visible card never loses to its own trash ghost). IntegrityRules.dedupe (pure, occurrence list in, verdict out): group by canonical identity, collapse case-spelled twins first - spellings with a live occurrence outrank trash-only spellings, then canonical lowercase, then lexicographically first; losers are silent strays (LoadWarning.caseTwinIgnored - spelling artifacts, never reminted) - then earlier-occurrence-wins across the surviving spelling's folders on a four-rung ladder: live-before-trashed, git path history rank, FS birth date (nil is no comparison, never .distantPast), traversal order. Occurrences are exactly the identity-bearing folders: lanes, cards, .trash entries - a UUID-shaped folder under a card is content. BoardLoader walks lanes as WalkedLane and builds Lane values only on the far side of the verdict, so a withheld card can never reach a snapshot; a name-only gate keeps the healthy-board cost at one dictionary pass, no disk reads. Withheld subtrees are still walked - a hand-copied lane's nested cards are their own withheld occurrences, reminted at the finest grain like the import boundary would have. A withheld trash entry's trashKinds reading leaves with it. The git rung is a seam (BoardLoader.IdentityHistoryRanker, one closure keyed by root-relative path) because base links no git machinery - base injects nothing and falls through; pro-m1 owns the ranker (card annotated). The heal: Defect.duplicateIdentity (signature duplicate:<path>:<id>) rides HealScheduler as the fourth scheduled heal, ordered last among the content heals because a remint renames folders and would stale the paths the same load handed the relocation and migration. BoardWriter.remintDuplicateIdentity re-verifies twice at write time - the folder still carries the losing identity AND something else still does (the vanished-duplicate race no-ops from either side) - then renames to a fresh v4 minted against the whole board's identity bag. A rename and nothing else: no index.md opened, no modified stamp, no modified-by clear; the receipt is heal-marked (pro-m1's committer splits it out, named by 06's kept Repair verb); no undo step - heals are not gestures. The notice is the design's own sentence ("Repaired duplicate id - 'Fix login'"; several fold to a count), a loss row on the relocation's reasoning; WriteOperation.repairDuplicateID carries the failure mirror. Fixture repair rode along: duplicate-order-tie-break.kanban had a lane and its own card sharing a UUID - a genuine duplicate the new pass correctly withholds; the folder rename landed infeae6d0, the matching test constant lands here. 66 tests added (DuplicateIdentityTests: the ladder rung by rung, the straddles, withheld-lane subtrees, remint idempotence and races, the one-heal-cycle window, all phrasing). 1804 green on both schemes. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user