Files
lanework/Kanban/Storage/IntegrityRules.swift
T
rzen 0463540aea 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
2026-07-29 18:19:52 -04:00

882 lines
49 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import Foundation
/// **The one pure vocabulary of object validity** — 01-storage-format.md § Validation and healing
/// (settled 2026-07-29), 02-architecture.md ▸ Components ▸ IntegrityRules.
///
/// Every rule in the storage format that refuses, tolerates, recovers or repairs is an instance of
/// one five-verdict taxonomy (`Verdict`), and this type is where the taxonomy's *rules* live: the
/// identity predicate and its canonical form, the reserved-name tables, per-kind index validation,
/// the trash's `kind` discriminator, the on-touch heals, and the typed `Defect` vocabulary the
/// loader reports.
///
/// ### It consolidates rules, it does not relocate enforcement
///
/// **Loader and Writer remain the enforcement points and call in.** The loader still walks and
/// still throws; the Writer still refuses and still writes. What moved here is the *deciding* — so
/// no mechanism re-derives a rule the next one also needs, and adding an object kind (the enhanced
/// schema's `comment`) adds its field table and shape rules in one place rather than a parallel
/// mechanism. A service smeared across the read/write/orchestration boundaries would be worse than
/// the discipline it replaced, which is why nothing here touches a `BoardStore` or schedules
/// anything: the scheduled-heal engine is `HealScheduler`, on the other side of the layering.
///
/// ### Pure
///
/// Every function here is a function of its arguments. Two of them are *about* the filesystem —
/// `placement(ofFolderNamed:inParentNamed:)` and `trashKind(kindValue:hasIdentityShapedChildIndex:)`
/// — and take the facts they need as parameters rather than reading disk themselves, so the rules
/// are pinned by the suite without a filesystem in the way. The one call that does read (the
/// squatter probe, `node(at:)`) is a plain `lstat` classification with no policy in it at all.
public enum IntegrityRules: Sendable {
// MARK: - The five verdicts
/// The taxonomy every detectable defect classifies into — exactly one verdict each, and the
/// verdict fixes everything downstream (surface, write behavior, race posture), so no mechanism
/// ever re-reasons its posture individually (01-storage-format.md § Validation and healing).
///
/// Nothing switches over this today, deliberately: it is the vocabulary the rules below are
/// *written in*, and each rule already names its own verdict at its own site. It exists as a
/// type so that a new rule has to answer "which verdict is this?" before it has anywhere to go.
public enum Verdict: Sendable, Equatable, CaseIterable {
/// Fail-fast — the defect defeats rendering or ordering (`BoardLoadError`).
case refuse
/// Readable-but-uneditable shapes: the file renders fine and every app write to it fails
/// loudly, per file (`FrontmatterDocument.UneditableShape`).
case refuseWrites
/// Outside the schema's claim — strays, symlinks, case-twins, lane- and board-level
/// `deleted:`. Preserved verbatim, logged, never rendered (`LoadWarning`).
case tolerate
/// A sensible reading exists (the coercion rulebook, last-wins, null-as-missing, the rescue
/// family): silent, read-side only, bytes preserved (`FieldValue`).
case coerce
/// An app-owned invariant is violated *and* a lossless canonical repair exists — **the only
/// verdict that writes** (`Defect`, healed inline, on touch, or on schedule).
case heal
}
// MARK: - The identity predicate and its canonical form
/// The hex characters `isIdentityShaped` accepts in each `-`-delimited group — **both cases**,
/// per the shape-only predicate below.
private static let identityGroupCharacters = Set("0123456789abcdefABCDEF")
/// **The identity predicate** — whether `name` has a UUID's shape: hex, `8-4-4-4-12`, **any
/// case and any version** (01-storage-format.md § Fractal layout ▸ Rules, "Name shape gates
/// level detection"). Deliberately shape-only: lowercase v4 is the app's *emission* rule, not
/// the gate.
///
/// - **Any case.** `uuidgen(1)` and `UUID().uuidString` both print uppercase, so a strict
/// lowercase gate would turn an agent's standard-tool card into a silently skipped stray —
/// the worst failure mode for a files-first app. Accept liberally, emit conservatively.
/// - **Any version.** The version and variant nibbles protect no invariant here — an agent's v7
/// is exactly as unique as a v4 — and recognizing the folder-naming *convention* is the job,
/// not re-deriving RFC 4122 conformance on every folder of every load.
///
/// Equivalent to "does `UUID(uuidString:)` parse it", kept as a manual scan because that is the
/// cheaper answer on the hot path and needs no bridging.
///
/// **One rule, one implementation**: `BoardLoader.isUUIDShaped` is this function under the
/// loader's own spelling, and `BoardWriter` reaches it through that. Recognizing a name is not
/// the same as *comparing* two of them — see `canonicalIdentity`.
public static func isIdentityShaped(_ name: String) -> Bool {
let groups = name.split(separator: "-", omittingEmptySubsequences: false)
guard groups.map(\.count) == [8, 4, 4, 4, 12] else { return false }
return groups.allSatisfy { $0.allSatisfy(identityGroupCharacters.contains) }
}
/// **The canonical form of an identity** — a folder name reduced to its UUID *value*.
///
/// Identity comparison is UUID-value equality, never string equality (01-storage-format.md
/// § Fractal layout ▸ Rules, settled): an arriving `55555555-…` and a resident `55555555-…`
/// spelled uppercase are **one** identity. Every identity-shaped name is ASCII hex and hyphens,
/// where locale-independent case folding is UUID-value canonicalization exactly.
///
/// **The one derivation** (settled 2026-07-29 — the fold): `ItemID`'s `==`/`hash(into:)`
/// canonicalize through this function, and so do the Writer's string-level checks, which
/// compare *paths* rather than model values (`BoardWriter.identities(inBoard:)`, the
/// import-boundary collision probe, `freshUUIDName`'s `taken` set). The Writer's former private
/// `canonicalIdentity` was a second copy of this one line; a second copy of a rule this
/// load-bearing is a bug waiting for the day the two disagree.
///
/// Total on any string: off-shape input compares by its own lowercasing, which is the harmless
/// reading (`ItemID` stays total for the same reason).
public static func canonicalIdentity(_ name: String) -> String {
name.lowercased()
}
// MARK: - The reserved-name tables
/// The board's trash container (01-storage-format.md § Deletion) — a **directory** name.
public static let trashFolderName = ".trash"
/// A card's attachment folder (01-storage-format.md § Attachments) — the one folder the app
/// ever creates under a card.
public static let attachmentsFolderName = "attachments"
/// The file every level's content lives in.
public static let indexFileName = "index.md"
/// **The card-level reserved names** (01-storage-format.md § Fractal layout ▸ Rules): the
/// card's own `index.md` plus the two reserved children. `comments` is listed because the schema
/// reserves the name, not because anything writes it yet.
///
/// **Compared lowercased**, because the filesystem this runs on usually is: a file spelled
/// `Index.md` *is* the card's index to `fileExists`, and a case-sensitive reservation check
/// would hand the loose-file relocation a card's own content to move into `attachments/`.
public static let reservedCardChildNames: Set<String> = [
indexFileName, attachmentsFolderName, "comments",
]
/// What kind of node a name is allowed to be.
///
/// `String`-backed so a defect's signature has a stable token to spell (`description` is prose
/// for a log line and must stay free to change without re-arming a memo).
public enum NodeKind: String, Sendable, Equatable, CustomStringConvertible {
case file
case directory
/// Never followed, never traversed, never resolved — `lstat` semantics everywhere in this
/// app (01-storage-format.md § Fractal layout ▸ Rules). A symlink is a *node that is there*,
/// whatever it points at, which is why it is its own case rather than the type of its
/// target.
case symlink
public var description: String {
switch self {
case .file: "a file"
case .directory: "a folder"
case .symlink: "a symbolic link"
}
}
}
/// **A board-root name the app claims** — the one scope on the verbatim promise
/// (01-storage-format.md § Fractal layout ▸ Rules: "Three board-root names are app-claimed, not
/// strays", plus `.trash/` from § Deletion).
public struct ClaimedName: Sendable, Equatable {
public let name: String
/// What the app needs the name to *be*. A node of any other kind is not a resident — it is
/// an invalid artifact on a name Lanework owns.
public let expected: NodeKind
/// Whether a wrong-kinded node on this name is displaced by the scheduled heal (ruled
/// 2026-07-29 — "Lanework owns the board"), or left exactly where it is.
///
/// `false` for the two names that are *destinations* or not the app's to police:
/// `CLAUDE.user.md` is where a markerless `CLAUDE.md` is rescued **to**, and freeing a
/// destination by a second displacement would cascade renames (the settled skip stands —
/// 08-agent-integration.md); `.gitignore` is seeded once and then the user's to edit
/// (06-history-undo.md ▸ Repository hygiene), and nothing in the app reads it.
public let displacesSquatters: Bool
}
/// The claimed-name table — **one place**, where these names were scattered across the loader,
/// the guide, and the trash writer before (02-architecture.md ▸ Components: "the reserved-name
/// tables … today scattered").
///
/// `CLAUDE.md`'s squatter is displaced by the **agent guide's** own heal, which already owns
/// that file's whole decision (`AgentGuide.Decision.displaceSquatterThenWrite`); `.trash`'s is
/// its own scheduled heal, because nothing else ever writes that name.
public static let claimedRootNames: [ClaimedName] = [
ClaimedName(name: trashFolderName, expected: .directory, displacesSquatters: true),
ClaimedName(name: "CLAUDE.md", expected: .file, displacesSquatters: true),
ClaimedName(name: "CLAUDE.user.md", expected: .file, displacesSquatters: false),
ClaimedName(name: ".gitignore", expected: .file, displacesSquatters: false),
]
/// The claimed names as the lane walk needs them: lowercased, for a `contains` against a
/// directory entry. Compared lowercased for `reservedCardChildNames`' reason.
public static let claimedRootNameSet: Set<String> = Set(claimedRootNames.map { $0.name.lowercased() })
/// What is sitting at `url`, by `lstat` and nothing else — `nil` when nothing is there.
///
/// **`attributesOfItem`, never `fileExists`**: a dangling symlink is a node that is *there*
/// (a move onto it would fail, and this app does not touch symlinks anyway), while `fileExists`
/// follows the link, finds nothing, and would call the name free.
public static func node(at url: URL) -> NodeKind? {
guard let type = (try? FileManager.default.attributesOfItem(atPath: url.path))?[.type]
as? FileAttributeType
else {
return nil
}
switch type {
case .typeSymbolicLink: return .symlink
case .typeDirectory: return .directory
default: return .file
}
}
/// The claimed-name defect at a board root, or `nil` when every claimed name is free or held by
/// the right kind of node — the **detection** half of the squatter-displacement ruling
/// (01-storage-format.md § Fractal layout ▸ Rules, ruled 2026-07-29).
///
/// Only names whose `displacesSquatters` is `true` can produce one, and only `.trash` is
/// answered here: `CLAUDE.md`'s squatter is the agent guide's, detected by `AgentGuide.inspect`
/// in the same read that decides everything else about that file, so answering it twice would be
/// two mechanisms racing to displace one node.
public static func squattedClaimedName(atBoardRoot root: URL) -> ClaimedNameSquatter? {
guard let claimed = claimedRootNames.first(where: { $0.name == trashFolderName }),
let found = node(at: root.appendingPathComponent(claimed.name)),
found != claimed.expected
else {
return nil
}
return ClaimedNameSquatter(name: claimed.name, found: found, expected: claimed.expected)
}
// MARK: - Object kinds
/// The kinds the schema knows (01-storage-format.md § Frontmatter ▸ Common to all levels, the
/// `kind` row). `comment` is reserved with the enhanced schema and deliberately absent until it
/// lands — an unrecognized value on disk is trusted as itself and never policed, so nothing here
/// has to anticipate it.
public enum ObjectKind: String, Sendable, Equatable, CaseIterable {
case board
case lane
case card
}
/// Where a folder's **position** places it — "level is position" (01-storage-format.md
/// § Fractal layout) as a value, decided from two names and nothing else.
public enum Placement: Sendable, Equatable {
/// Its parent is identity-shaped, so it is a card: a lane's children are the only
/// identity-bearing folders under an identity-bearing folder.
case card
/// Identity-shaped, under something that is neither a lane nor the trash — a lane.
case lane
/// Inside `.trash/`, where the container is flat and **position cannot answer**: use
/// `trashKind(kindValue:hasIdentityShapedChildIndex:)`.
case insideTrash
/// Position says nothing. A board root reaches this (its folder name is a Finder document
/// name, not an identity), and so does any hand-named folder — which is why the answer is
/// "unknown" rather than "board": guessing here would stamp `kind: board` onto whatever a
/// caller happened to point at.
case unknown
}
/// The placement rule, in the order the questions can be answered.
///
/// The trash check sits *between* the two identity checks deliberately: a trashed card and a
/// live lane are both identity-shaped folders whose parent is not, and the container is the only
/// thing that tells them apart.
public static func placement(ofFolderNamed name: String, inParentNamed parent: String) -> Placement {
if isIdentityShaped(parent) { return .card }
if parent.lowercased() == trashFolderName { return .insideTrash }
if isIdentityShaped(name) { return .lane }
return .unknown
}
/// **The trash's `kind` discriminator** (01-storage-format.md § Deletion, re-ruled 2026-07-29 —
/// the value-names-the-kind posture): depth defines meaning on the live board, but the trash is
/// flat, and an empty lane folder is shape-identical to a card folder.
///
/// **The value is trusted outright** — `kind: lane` → lane, `kind: card` → card — so an external
/// writer's `kind: lane` on what looks card-shaped is honored, never policed and never
/// corroborated. An unrecognized value or no key at all falls through to **shape**:
/// identity-shaped children with their own `index.md` → lane (the key backfills on the next
/// touch), else card.
///
/// `kind: board` in the trash is *not* a third answer: a board is not a thing that can be
/// trashed, so the value is unrecognized here and shape decides — the same shrug an arbitrary
/// string gets.
///
/// The shape half is `@autoclosure` so that the rule stays a pure function of two facts while
/// its caller pays for the directory listing **only when the value did not answer** — which on
/// a board written by this app is never.
public static func trashKind(
kindValue: String?,
hasIdentityShapedChildIndex: @autoclosure () -> Bool
) -> ObjectKind {
switch kindValue.flatMap(ObjectKind.init(rawValue:)) {
case .lane: return .lane
case .card: return .card
case .board, nil: return hasIdentityShapedChildIndex() ? .lane : .card
}
}
// MARK: - Per-field validation (the rulebook)
/// `schema`, validated: present, well-formed, not newer than this app (01-storage-format.md
/// § Malformed input). Required at every level.
public static func validatedSchema(
in document: FrontmatterDocument,
path: String,
supportedSchema: Int
) throws(BoardLoadError) -> Int {
switch document.schema {
case .missing:
throw BoardLoadError(path: path, reason: .missingSchema)
case let .malformed(raw):
throw BoardLoadError(path: path, reason: .malformedSchema(raw: raw))
case let .valid(value):
guard value <= supportedSchema else {
throw BoardLoadError(path: path, reason: .schemaNewerThanApp(found: value))
}
return value
}
}
/// `order`, validated: present and well-formed. Required on lanes and cards, **never** on the
/// board itself — which is the whole of the per-kind difference in the tables today.
public static func validatedOrder(
in document: FrontmatterDocument,
path: String
) throws(BoardLoadError) -> Double {
switch document.order {
case .missing:
throw BoardLoadError(path: path, reason: .missingOrder)
case let .malformed(raw):
throw BoardLoadError(path: path, reason: .malformedOrder(raw: raw))
case let .valid(value):
return value
}
}
/// Whether an object of `kind` must carry `order` — the per-kind field table, as a rule rather
/// than as two hand-written call sites in the loader's walk.
public static func requiresOrder(_ kind: ObjectKind) -> Bool {
switch kind {
case .board: false
case .lane, .card: true
}
}
/// **Per-kind index validation** — the loader's own checks, in its own order, over bytes that
/// need not be on disk yet (02-architecture.md ▸ Components: "the card validator generalized per
/// kind — board, lane, card, the enhanced schema's comment when it lands").
///
/// Exactly the checks `BoardLoader.load` runs on an object of that kind, through its own
/// functions: decode + parse, then `schema`, then `order` where the kind requires it. Nothing
/// further is checked, because nothing else *is*: `title` is optional, unknown keys are the
/// point of the outlet the card validator serves, and the body is free text.
///
/// It deliberately does **not** check `uneditableShape`: that refusal exists for surgical span
/// edits, and the raw-source Apply this serves replaces the whole file — a flow-mapping
/// frontmatter is precisely one of the things the escape hatch exists to let a user rewrite.
public static func validateIndex(
_ data: Data,
path: String,
kind: ObjectKind,
supportedSchema: Int
) throws(BoardLoadError) -> FrontmatterDocument {
let document = try BoardLoader.parseDocument(data, path: path)
_ = try validatedSchema(in: document, path: path, supportedSchema: supportedSchema)
if requiresOrder(kind) {
_ = try validatedOrder(in: document, path: path)
}
return document
}
/// Why this document refuses every app write, or `nil` when it can be edited in place — the
/// **refuse-writes** verdict's whole rule (01-storage-format.md § Frontmatter, the
/// readable-but-uneditable shapes).
///
/// The analysis itself lives on `FrontmatterDocument`, computed at parse from the span
/// structure it alone has; naming it here is what puts the verdict in the vocabulary rather than
/// leaving it as a property one call site happens to read.
public static func uneditableShape(of document: FrontmatterDocument) -> FrontmatterDocument.UneditableShape? {
document.uneditableShape
}
// MARK: - On-touch heals
/// A latent defect fixed by folding into a write that is **already rewriting that file**
/// (01-storage-format.md § Validation and healing: "on-touch when the defect is latent"). Never
/// a scheduled sweep, never a write of its own — an on-touch heal rides its host write's single
/// atomic rewrite and its host's `modified` stamp, and composes no event of its own.
public enum OnTouchHeal: Sendable, Equatable {
/// `kind` was missing and has been stamped with the object's own kind (re-ruled 2026-07-29
/// — the common-schema row). **On-touch only, never a scheduled backfill sweep**: the key is
/// consequential only inside `.trash/`, and a sweep would rewrite every file on the board to
/// add a key that is redundant with position everywhere else.
case kindBackfilled(ObjectKind)
/// A key written twice collapsed to its winning (last) occurrence — the span editor's
/// duplicate-key twin removal (`FrontmatterDocument.set`). Named here because it *is* an
/// on-touch heal and was only ever documented as an editing detail: last-wins is the read
/// rule, and the write that touches the key is where the twins stop being able to resurrect.
case duplicateKeyTwinsRemoved(key: String)
/// A value that needed quoting got it on its first app write — the colon rescue's
/// quote-on-first-write (`FrontmatterValue.emitScalar`). The same class as the twin removal
/// and named for the same reason: the app writes the value correctly the first time it has
/// any reason to write it at all, and never on a file it was not already rewriting.
case quotedOnFirstWrite(key: String)
}
/// **The on-touch heal seam**: the pending latent work on the document a write is already
/// rewriting, applied (02-architecture.md ▸ Components: "on-touch heals live at the Writer's
/// `updateIndex` seam, which consults IntegrityRules for pending on-touch work on the file it is
/// rewriting").
///
/// Only the `kind` backfill is applied *here*; the other two members of the class are applied by
/// `FrontmatterDocument`'s own editor on every key it writes, and are named in `OnTouchHeal`
/// rather than re-implemented. That is the honest shape of "the same class, named, no behavior
/// change".
///
/// - **Missing only.** A present `kind` is never rewritten, never corroborated, and never
/// stripped — the value names the kind and consumers trust it outright. An explicit `kind:`
/// with nothing after it reads as missing, like every other null (the null-as-missing rule),
/// and so backfills.
/// - **`kind == nil` stamps nothing.** Position cannot always answer (`Placement.unknown`), and
/// a guessed kind written to disk would be worse than an absent one: the trash discriminator
/// trusts what it finds.
/// - Parameter kind: `@autoclosure` so a caller whose answer costs a directory listing (the
/// trash's shape fallback) pays for it only on a file that actually needs the backfill.
@discardableResult
public static func healOnTouch(
_ document: inout FrontmatterDocument,
kind: @autoclosure () -> ObjectKind?
) -> [OnTouchHeal] {
guard document.kind.isMissing, let kind = kind() else { return [] }
document.set(FrontmatterKeys.kind, to: .string(kind.rawValue))
return [.kindBackfilled(kind)]
}
// MARK: - The typed defect stream
/// **One defect, typed** — what the loader reports as pending *work*, as distinct from the
/// tolerate-tier `LoadWarning`s it reports as information (02-architecture.md ▸ Components:
/// "`LoadResult`'s ad-hoc repair channels (loose files, legacy tombstones) become one typed
/// defect stream; tolerate-tier warnings stay warnings").
///
/// The distinction is the whole reason the two channels are not one: a warning says "this was
/// ignored, it is staying exactly where it is, there is nothing to do", and a defect says the
/// opposite. Folding defects into the warning channel would also throw away everything the heal
/// needs (which lane, which card, which title, which names) and force it to be re-derived from a
/// display string.
public enum Defect: Sendable, Equatable {
/// A card holding files that belong in its `attachments/` (the loose-file carve-out).
case looseCardFiles(LooseCardFiles)
/// An item carrying a legacy `deleted:` key (the retired tombstone model's migration input).
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`).
///
/// `staleAgentGuide` has no `Defect` case, and that asymmetry is honest rather than an
/// oversight: the guide's defect is a property of one board-root file's *version marker*,
/// read at the moment of healing (`AgentGuide.inspect`), not something a tree walk reports.
/// It is a class here because the engine treats it exactly like the others — same gates,
/// same memo, same clear-on-success.
public enum Class: Sendable, Equatable, Hashable, CaseIterable {
case looseCardFiles
case legacyTombstone
case claimedNameSquatted
case duplicateIdentity
case staleAgentGuide
}
public var healClass: Class {
switch self {
case .looseCardFiles: .looseCardFiles
case .legacyTombstone: .legacyTombstone
case .claimedNameSquatted: .claimedNameSquatted
case .duplicateIdentity: .duplicateIdentity
}
}
/// **The defect's identity as a comparable string** — the memo's unit (01-storage-format.md
/// § Validation and healing: "re-armed only by a changed defect signature").
///
/// What matters is the *identity* of the work, never the order the walk happened to meet it
/// in, which is why the engine compares sets of these rather than arrays of defects: two
/// loads of an unchanged tree must compare equal even if a lane's folder-name ordering
/// shifted underneath them.
public var signatures: [String] {
switch self {
case let .looseCardFiles(work):
work.fileNames.map { "loose:\(work.laneID.rawValue)/\(work.cardID.rawValue)/\($0)" }
case let .legacyTombstone(work):
["tombstone:\(work.laneID.rawValue)/\(work.cardID?.rawValue ?? "")"]
case let .claimedNameSquatted(work):
// The node *kind* is part of the picture: a squatter replaced by a different kind
// 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
/// One card found holding files that belong in its `attachments/` — everything the relocation and
/// its notice need, and nothing more (01-storage-format.md § Fractal layout ▸ Rules, settled
/// 2026-07-28, "Lanework-owns-the-board").
///
/// `title` is the card's 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. The path is
/// carried as its two identity components rather than as a URL, `ItemPath`'s convention, so the
/// write derives its path from the store's *current* root.
public struct LooseCardFiles: Sendable, Equatable {
public let laneID: ItemID
public let cardID: ItemID
public let title: String?
/// The loose files' names, in Finder order (`BoardLoader.looseFileNames`). Never empty — a card
/// with nothing loose contributes no defect at all.
public let fileNames: [String]
public init(laneID: ItemID, cardID: ItemID, title: String?, fileNames: [String]) {
self.laneID = laneID
self.cardID = cardID
self.title = title
self.fileNames = fileNames
}
}
/// One item found carrying a legacy `deleted:` key — everything its migration and notice need, and
/// nothing more (01-storage-format.md § Deletion: "Legacy `deleted:` keys migrate on load-and-write,
/// never destroy").
///
/// The path is carried as its identity components rather than as a URL — `LooseCardFiles`'
/// convention, for its reason. `title` is the item's as written, `nil` for an untitled one.
public struct LegacyTombstone: Sendable, Equatable {
/// Which migration this item takes — the two are genuinely different acts, not one act at two
/// levels: a card *moves* (into `.trash/`, at a minted top-of-trash rank) and a lane stays
/// exactly where it is (the key is stripped and it returns live).
public enum Kind: Sendable, Equatable {
case card
case lane
}
public let kind: Kind
/// The lane's own identity for `.lane`; the card's **containing** lane for `.card` — the context
/// the relocation needs to find the folder at all.
public let laneID: ItemID
/// The card's identity for `.card`, `nil` for `.lane`. Two fields rather than an enum payload so
/// the common "which folder is this" question is one path join at every call site.
public let cardID: ItemID?
public let title: String?
public init(kind: Kind, laneID: ItemID, cardID: ItemID?, title: String?) {
self.kind = kind
self.laneID = laneID
self.cardID = cardID
self.title = title
}
}
/// A claimed board-root name held by the wrong kind of node — a file or symlink squatting `.trash`,
/// a directory or symlink squatting `CLAUDE.md` (01-storage-format.md § Fractal layout ▸ Rules,
/// ruled 2026-07-29: "A claimed name held by the wrong kind of node is Lanework's to heal — by
/// displacement").
///
/// **An invalid artifact, not a resident.** The heal moves it aside via the Finder-style rename
/// ladder (`.trash` → `.trash 2`), preserved verbatim and never destroyed, with a warning-tone
/// notice naming old and new — the invariant that survives is displacement-never-destruction.
public struct ClaimedNameSquatter: Sendable, Equatable {
/// The claimed name, exactly as the app spells it (`.trash`, `CLAUDE.md`).
public let name: String
/// What is actually sitting there — never followed if it is a symlink.
public let found: IntegrityRules.NodeKind
/// What the app needs the name to be.
public let expected: IntegrityRules.NodeKind
public init(name: String, found: IntegrityRules.NodeKind, expected: IntegrityRules.NodeKind) {
self.name = name
self.found = found
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
}
}