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 = [ 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 = 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** — `""`, `"/"`, /// `".trash/"`. /// /// 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 1–3 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 } }