The kind: comment field table lands in IntegrityRules (the per-kind hook's first exercise), CommentThread reads one card's thread window-scoped (the board walk stays O(cards)), and CommentWriter gains the five gestures: draft save, post (rename .draft to a fresh UUID, created/modified restamped in the bracket), edit, delete into comments/.trash/, and the purge with its crash-residue memo. Post and delete register move-based undo steps; draft saves, edits, and the purge deliberately register nothing (13's no-capture rule). Copy boundaries strip comments/.trash, carry .draft verbatim, and remint threads; comments graduates to a displacing claimed name, with .draft, .trash, and a comment's attachments claimed one level down. CommentPath classifies changed paths into the 06 verb family for later announcer/composer wiring. One stated narrowing pending a ruling (filed on the findings board): the copy transaction's refuse-whole preflight stays cards-and-lanes — an unstampable copied comment copies verbatim with a log line, because comment defects never refuse. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
1158 lines
66 KiB
Swift
1158 lines
66 KiB
Swift
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. **Fractal**: a comment folder has one too (§ Enhanced schema).
|
||
public static let attachmentsFolderName = "attachments"
|
||
|
||
/// The file every level's content lives in.
|
||
public static let indexFileName = "index.md"
|
||
|
||
/// A card's **comment thread** (01-storage-format.md § Enhanced schema) — a plain reserved
|
||
/// child, never a level and never identity; the identities are the UUID folders inside it.
|
||
public static let commentsFolderName = "comments"
|
||
|
||
/// The card's single comment draft, inside `comments/` — "a reserved dot-named folder holding
|
||
/// ordinary comment schema … excluded from the thread listing" (§ Enhanced schema, ruled
|
||
/// 2026-07-29).
|
||
public static let commentDraftFolderName = ".draft"
|
||
|
||
/// The thread's own trash — **the board's name one level down**, deliberately the same spelling:
|
||
/// "the materialized-trash pattern one level down, joining `.draft` in the claimed names"
|
||
/// (§ Enhanced schema). Undo's backing store, never a UI surface.
|
||
public static let commentTrashFolderName = trashFolderName
|
||
|
||
/// **The card-level reserved names** (01-storage-format.md § Fractal layout ▸ Rules): the
|
||
/// card's own `index.md` plus the two reserved children.
|
||
///
|
||
/// **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, commentsFolderName,
|
||
]
|
||
|
||
/// **The same table inside one comment folder** — "a card's anatomy one level down, so the
|
||
/// fractal rules apply verbatim" (01-storage-format.md § Enhanced schema). A comment has no
|
||
/// `comments/` of its own: replies are deliberately deferred and flat is this iteration's rule.
|
||
public static let reservedCommentChildNames: Set<String> = [
|
||
indexFileName, attachmentsFolderName,
|
||
]
|
||
|
||
/// 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 card-level claimed names** — the same table one level down (01-storage-format.md § Fractal
|
||
/// layout ▸ Rules, extended 2026-07-29: "**The rule is level-uniform**: a card's reserved child
|
||
/// names are claimed the same way — a regular file or symlink squatting `attachments` (a directory
|
||
/// name) displaces by the same ladder (`attachments` → `attachments 2`), so imports, Finder drops,
|
||
/// and the sidebar listing never fail one gesture at a time against a squatted name").
|
||
///
|
||
/// **`comments` graduated with the feature** (the timing principle, run forwards): 01 called the
|
||
/// reserved-but-unconsumed name "the timing principle's own illustration" — "a wrong-kind holder is
|
||
/// a tolerated stray today and joins the scheduled class **the day the name becomes load-bearing**".
|
||
/// That day is this one: a file or symlink wearing `comments` now breaks the card window's whole
|
||
/// thread — no draft can be saved, no comment posted, nothing read — which is exactly the
|
||
/// "proactive when the defect is load-bearing now" condition (§ Validation and healing).
|
||
/// `attachments` was load-bearing already and displaced from the start.
|
||
///
|
||
/// `index.md` is deliberately not here. It is not a *reserved child* the app protects from
|
||
/// squatters — it is the card's content, and a directory named `index.md` makes the folder an
|
||
/// index-less stray the loader already skips with a warning (§ Fractal layout ▸ Rules). Displacing it
|
||
/// would mean the app deciding a folder's content is a squatter.
|
||
public static let claimedCardChildNames: [ClaimedName] = [
|
||
ClaimedName(name: attachmentsFolderName, expected: .directory, displacesSquatters: true),
|
||
ClaimedName(name: commentsFolderName, expected: .directory, displacesSquatters: true),
|
||
]
|
||
|
||
/// **The names claimed inside a card's `comments/`** — the thread's two lifecycle folders
|
||
/// (01-storage-format.md § Enhanced schema: "`.draft` joins the claimed names (a wrong-kind node
|
||
/// squatting it displaces by the ladder)", and `comments/.trash/` "joining `.draft` in the claimed
|
||
/// names").
|
||
///
|
||
/// Both displace: a file wearing `.draft` makes the composer unsaveable and a file wearing
|
||
/// `.trash` makes every comment delete fail, so neither is latent while it stands.
|
||
public static let claimedCommentThreadNames: [ClaimedName] = [
|
||
ClaimedName(name: commentDraftFolderName, expected: .directory, displacesSquatters: true),
|
||
ClaimedName(name: commentTrashFolderName, expected: .directory, displacesSquatters: true),
|
||
]
|
||
|
||
/// **The card's table, one level down** — a comment's own `attachments`, claimed exactly as a
|
||
/// card's is (§ Enhanced schema: "displacement of a squatted `attachments`" is named among the
|
||
/// fractal rules that "apply verbatim").
|
||
public static let claimedCommentChildNames: [ClaimedName] = [
|
||
ClaimedName(name: attachmentsFolderName, expected: .directory, displacesSquatters: true),
|
||
]
|
||
|
||
/// 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)
|
||
}
|
||
|
||
/// The claimed-name defects inside one **card** folder — the level-uniform half of the same ruling
|
||
/// (`claimedCardChildNames`, extended 2026-07-29).
|
||
///
|
||
/// Only names whose `displacesSquatters` is `true` can produce one, which today means `attachments`
|
||
/// and only `attachments`: a `comments` held by the wrong kind of node is a tolerated stray until
|
||
/// the feature consumes the name.
|
||
///
|
||
/// **A plural answer, unlike the board root's**, because the reason the root's is singular does not
|
||
/// apply here: there, the second displacing name (`CLAUDE.md`) is the agent guide's own to heal, so
|
||
/// answering it twice would be two mechanisms racing one node. Nothing else owns a card's children,
|
||
/// so this returns every offender it finds and the table stays the only thing to edit when
|
||
/// `comments` graduates.
|
||
///
|
||
/// - Parameter path: the card folder's path **relative to the board root**, carried into the defect
|
||
/// so the write lands wherever the board lives at heal time (`LooseCardFiles`' convention).
|
||
public static func squattedClaimedNames(inCardAt cardFolder: URL, path: String) -> [ClaimedNameSquatter] {
|
||
squatters(among: claimedCardChildNames, in: cardFolder, at: .card(path: path))
|
||
}
|
||
|
||
/// The claimed-name defects inside one card's **`comments/`** — `.draft` and `.trash`
|
||
/// (`claimedCommentThreadNames`, 01-storage-format.md § Enhanced schema).
|
||
///
|
||
/// **Window-scoped, unlike its card-level twin**: the board walk stays O(cards) and never opens a
|
||
/// thread (§ Enhanced schema — "Comments are window-scoped, outside the board snapshot"), so this
|
||
/// is asked by the card window's own thread read, not by the loader.
|
||
///
|
||
/// - Parameter cardPath: the card folder's path relative to the board root, so the heal lands
|
||
/// wherever the board lives at write time (`LooseCardFiles`' convention).
|
||
public static func squattedClaimedNames(
|
||
inCommentThreadAt threadFolder: URL,
|
||
cardPath: String
|
||
) -> [ClaimedNameSquatter] {
|
||
squatters(among: claimedCommentThreadNames, in: threadFolder, at: .commentThread(cardPath: cardPath))
|
||
}
|
||
|
||
/// The claimed-name defects inside **one comment folder** — its `attachments`, the card's rule
|
||
/// read one level down (`claimedCommentChildNames`). Window-scoped, for its sibling's reason.
|
||
///
|
||
/// - Parameter path: the comment folder's path relative to the board root.
|
||
public static func squattedClaimedNames(inCommentAt commentFolder: URL, path: String) -> [ClaimedNameSquatter] {
|
||
squatters(among: claimedCommentChildNames, in: commentFolder, at: .comment(path: path))
|
||
}
|
||
|
||
/// The shared body of the three plural detections: one table, one folder, one location. The board
|
||
/// root's singular answer stays its own, for the reason its doc comment gives.
|
||
private static func squatters(
|
||
among table: [ClaimedName],
|
||
in folder: URL,
|
||
at location: ClaimedNameSquatter.Location
|
||
) -> [ClaimedNameSquatter] {
|
||
table.compactMap { claimed in
|
||
guard claimed.displacesSquatters,
|
||
let found = node(at: folder.appendingPathComponent(claimed.name)),
|
||
found != claimed.expected
|
||
else {
|
||
return nil
|
||
}
|
||
return ClaimedNameSquatter(
|
||
name: claimed.name,
|
||
found: found,
|
||
expected: claimed.expected,
|
||
location: location
|
||
)
|
||
}
|
||
}
|
||
|
||
// MARK: - Object kinds
|
||
|
||
/// The kinds the schema knows (01-storage-format.md § Frontmatter ▸ Common to all levels, the
|
||
/// `kind` row). `comment` joined them with the enhanced schema's storage (§ Enhanced schema, the
|
||
/// `kind: comment` field table) — the first kind whose position is not a *level*: a comment lives
|
||
/// under a card's `comments/`, which is a reserved child rather than a depth.
|
||
public enum ObjectKind: String, Sendable, Equatable, CaseIterable {
|
||
case board
|
||
case lane
|
||
case card
|
||
case comment
|
||
}
|
||
|
||
/// 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
|
||
/// Under a card's `comments/` — a posted comment, or the `.draft` that is one in every
|
||
/// respect but its name (01-storage-format.md § Enhanced schema: "ordinary comment schema").
|
||
case comment
|
||
/// 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.
|
||
///
|
||
/// **The `comments/` check sits ahead of the trash's**, and ahead of the lane's for the reason
|
||
/// that matters: a posted comment is an identity-shaped folder whose parent is not identity-shaped
|
||
/// — shape-identical to a lane — so without it every comment would read as a lane and the on-touch
|
||
/// backfill would stamp `kind: lane` into a thread.
|
||
///
|
||
/// **One ambiguity two names cannot resolve**: `comments/.trash/<uuid>` answers `.insideTrash`
|
||
/// here, because `.trash` is the board's spelling one level down. `placement(ofFolder:)` is the
|
||
/// form that can tell them apart, and every caller holding a URL should use it.
|
||
public static func placement(ofFolderNamed name: String, inParentNamed parent: String) -> Placement {
|
||
if isIdentityShaped(parent) { return .card }
|
||
if parent.lowercased() == commentsFolderName { return .comment }
|
||
if parent.lowercased() == trashFolderName { return .insideTrash }
|
||
if isIdentityShaped(name) { return .lane }
|
||
return .unknown
|
||
}
|
||
|
||
/// The same rule read off a **URL** — the only form that can tell the board's `.trash/` from a
|
||
/// comment thread's own one level down, since the two share a name and the two-name rule sees
|
||
/// only the name.
|
||
public static func placement(ofFolder url: URL) -> Placement {
|
||
let parent = url.deletingLastPathComponent()
|
||
let placement = placement(ofFolderNamed: url.lastPathComponent, inParentNamed: parent.lastPathComponent)
|
||
guard placement == .insideTrash,
|
||
parent.deletingLastPathComponent().lastPathComponent.lowercased() == commentsFolderName
|
||
else {
|
||
return placement
|
||
}
|
||
return .comment
|
||
}
|
||
|
||
/// **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, and neither is `kind: comment`: neither is
|
||
/// a thing that can be in the *board's* trash (a deleted comment moves into its own thread's
|
||
/// `comments/.trash/`, 01-storage-format.md § Enhanced schema), so both values are 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, .comment, 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.
|
||
///
|
||
/// **A comment carries none, and never gains one** (01-storage-format.md § Enhanced schema:
|
||
/// "**No `title`, no `order`**" — "Ordering is chronology, not ranks", because a conversation's
|
||
/// semantics *are* chronology and tracker-synced comments carry independent clocks where minted
|
||
/// ranks would interleave arbitrarily).
|
||
public static func requiresOrder(_ kind: ObjectKind) -> Bool {
|
||
switch kind {
|
||
case .board, .comment: 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)
|
||
/// A **card** carrying a legacy `deleted:` key (the retired tombstone model's surviving
|
||
/// migration input; a lane's key is inert and tolerated instead).
|
||
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)
|
||
|
||
/// **A coerce-tier fallback**: one file's lenient fields that had no sensible reading and
|
||
/// rendered as their defaults (ruled 2026-07-29 — "A no-sensible-reading fallback logs: field,
|
||
/// path, and raw text, carried as coerce-tier entries in the integrity service's Defect
|
||
/// stream").
|
||
///
|
||
/// **The one case in this enum that is not work**, which is the ruling rather than a
|
||
/// contradiction: the design puts coerce-tier fallbacks in *this* stream on purpose, because
|
||
/// this is where "an observed-in-the-wild shape can later be promoted to a heuristic heal or a
|
||
/// notice" — and the promotion would happen right here, by giving the case a heal class. Until
|
||
/// then it has none (`healClass` answers `nil`), raises no banner, and changes no behavior: the
|
||
/// value already rendered as its default and the bytes on disk are untouched. It is
|
||
/// observability, carried in the vocabulary that would act on it if the app ever decided to.
|
||
case coercedFrontmatter(CoercedFrontmatter)
|
||
|
||
/// 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.
|
||
///
|
||
/// `commentTrashResidue` is the second such class, for the same reason one level down: the
|
||
/// residue is whatever a crashed session left in one card's `comments/.trash/`, read at the
|
||
/// moment the card window opens (01-storage-format.md § Enhanced schema — "purged when the
|
||
/// card window closes; crash residue sweeps at the next card-window open, armed-then-cleared
|
||
/// like every heal memo"). The board walk never opens a thread, so no tree walk could report
|
||
/// it.
|
||
public enum Class: Sendable, Equatable, Hashable, CaseIterable {
|
||
case looseCardFiles
|
||
case legacyTombstone
|
||
case claimedNameSquatted
|
||
case duplicateIdentity
|
||
case staleAgentGuide
|
||
case commentTrashResidue
|
||
}
|
||
|
||
/// The scheduled-heal class this defect belongs to, or **`nil` where there is no heal** — the
|
||
/// coerce tier (`coercedFrontmatter`), which is carried for observability and acted on by
|
||
/// nothing. Optional rather than a synthetic class, because a class *is* a memo key and a
|
||
/// banner-posture row in the engine (`HealScheduler`): inventing one for work that does not
|
||
/// exist would arm a memo against a repair nobody wrote.
|
||
public var healClass: Class? {
|
||
switch self {
|
||
case .looseCardFiles: .looseCardFiles
|
||
case .legacyTombstone: .legacyTombstone
|
||
case .claimedNameSquatted: .claimedNameSquatted
|
||
case .duplicateIdentity: .duplicateIdentity
|
||
case .coercedFrontmatter: nil
|
||
}
|
||
}
|
||
|
||
/// **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. The location leads, so two cards squatting `attachments` are two
|
||
// pieces of work — and a board-root squatter signs exactly as it always did.
|
||
["claimed:\(work.location.signatureComponent)\(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)"]
|
||
case let .coercedFrontmatter(work):
|
||
// One signature per field, `looseCardFiles`' shape: the unit of the observation is a
|
||
// field, and a file whose `width` healed while its `icon` did not is a changed picture.
|
||
// Nothing memoizes these today — there is no heal to guard — but a signature is what a
|
||
// defect *is* in this vocabulary, and omitting it would make this case the one members
|
||
// of the stream cannot be compared by.
|
||
work.fields.map { "coerce:\(work.path):\($0.key)" }
|
||
}
|
||
}
|
||
}
|
||
|
||
// 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 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 **card** found carrying a legacy `deleted:` key — everything its migration and notice need,
|
||
/// and nothing more (01-storage-format.md § Deletion: "a card carrying `deleted:` is relocated into
|
||
/// `.trash/` (key removed)").
|
||
///
|
||
/// **Cards only, since 2026-07-29.** The rule used to have two halves, and the lane half is retired
|
||
/// wholesale: "a lane carrying `deleted:` simply loads live with the key ignored — no migration
|
||
/// machinery, no key-strip write, no notice". A lane's key is now the tolerate tier's business
|
||
/// (`LoadWarning.laneLevelDeletedIgnored`), which is why there is no `kind` here to switch on: the
|
||
/// one surviving act is a move, and a payload that could spell the other one would be a defect
|
||
/// nothing can heal.
|
||
///
|
||
/// The path is carried as its identity components rather than as a URL — `LooseCardFiles`'
|
||
/// convention, for its reason. `title` is the card's as written, `nil` for an untitled one.
|
||
public struct LegacyTombstone: Sendable, Equatable {
|
||
|
||
/// The card's **containing** lane — the context the relocation needs to find the folder at all.
|
||
public let laneID: ItemID
|
||
|
||
/// The card itself.
|
||
public let cardID: ItemID
|
||
|
||
public let title: String?
|
||
|
||
public init(laneID: ItemID, cardID: ItemID, title: String?) {
|
||
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.
|
||
/// **The rule is level-uniform** (extended 2026-07-29): a card's `attachments` is claimed exactly as the
|
||
/// board root's `.trash` is, and displaces by the same ladder — which is why `location` exists rather
|
||
/// than a second defect type. The heals compose: the displaced file, now an ordinary loose file, rides
|
||
/// the next loose-file relocation into the real `attachments/`.
|
||
public struct ClaimedNameSquatter: Sendable, Equatable {
|
||
|
||
/// **Which claimed name this is** — the board root's, or one card's reserved child.
|
||
///
|
||
/// A path rather than a URL, relative to the board root, so the heal joins it onto the store's
|
||
/// *current* root and a board renamed mid-session heals at its new location (`LooseCardFiles`' and
|
||
/// `IdentityOccurrence.path`'s convention).
|
||
public enum Location: Sendable, Equatable {
|
||
case boardRoot
|
||
case card(path: String)
|
||
/// A card's `comments/` container — where `.draft` and `.trash` are claimed
|
||
/// (01-storage-format.md § Enhanced schema). `cardPath` names the *card*, so the one thing a
|
||
/// caller has to know is where the card is, exactly as `.card` asks.
|
||
case commentThread(cardPath: String)
|
||
/// One comment's own folder — where `attachments` is claimed, the card's rule read one level
|
||
/// down. `path` is the comment folder's, root-relative.
|
||
case comment(path: String)
|
||
|
||
/// The folder the claimed name lives in, under `root`.
|
||
public func folder(under root: URL) -> URL {
|
||
switch self {
|
||
case .boardRoot:
|
||
root
|
||
case let .card(path), let .comment(path):
|
||
root.appendingPathComponent(path, isDirectory: true)
|
||
case let .commentThread(cardPath):
|
||
root
|
||
.appendingPathComponent(cardPath, isDirectory: true)
|
||
.appendingPathComponent(IntegrityRules.commentsFolderName, isDirectory: true)
|
||
}
|
||
}
|
||
|
||
/// The location as a signature component — `""` for the board root, so the existing root-level
|
||
/// signature spelling is unchanged and only a nested defect adds path segments.
|
||
var signatureComponent: String {
|
||
switch self {
|
||
case .boardRoot: ""
|
||
case let .card(path), let .comment(path): path + "/"
|
||
case let .commentThread(cardPath): cardPath + "/" + IntegrityRules.commentsFolderName + "/"
|
||
}
|
||
}
|
||
}
|
||
|
||
/// The claimed name, exactly as the app spells it (`.trash`, `CLAUDE.md`, `attachments`).
|
||
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
|
||
/// Where the name lives. Defaults to the board root, which is where every squatter was before the
|
||
/// rule went level-uniform — so the root-level call sites and their tests read exactly as they did.
|
||
public let location: Location
|
||
|
||
public init(
|
||
name: String,
|
||
found: IntegrityRules.NodeKind,
|
||
expected: IntegrityRules.NodeKind,
|
||
location: Location = .boardRoot
|
||
) {
|
||
self.name = name
|
||
self.found = found
|
||
self.expected = expected
|
||
self.location = location
|
||
}
|
||
}
|
||
|
||
/// 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
|
||
}
|
||
}
|
||
|
||
/// One file's **coerce-tier fallbacks**: the lenient fields whose value had no sensible reading, so the
|
||
/// field rendered as its default (01-storage-format.md § Frontmatter, ruled 2026-07-29: "A
|
||
/// no-sensible-reading fallback logs: field, path, and raw text, carried as coerce-tier entries in the
|
||
/// integrity service's Defect stream — the one place where an observed-in-the-wild shape can later be
|
||
/// promoted to a heuristic heal or a notice; no banner, no behavior change").
|
||
///
|
||
/// **Per file, not per field** — `LooseCardFiles`' shape and for its reason: the walk meets a document
|
||
/// once and reads all of its fields there, so one record per `index.md` is what the loader naturally
|
||
/// has, and a caller that wants per-field granularity has `fields` (and the per-field `signatures`).
|
||
///
|
||
/// The path is root-relative, as every load-side path in this app is (`BoardLoadError.path`,
|
||
/// `IdentityOccurrence.path`) — it names the `index.md`, because that is the file whose bytes were
|
||
/// read and the thing a developer would open.
|
||
public struct CoercedFrontmatter: Sendable, Equatable {
|
||
/// The `index.md`'s path relative to the board root — `"index.md"`, `"<lane>/index.md"`,
|
||
/// `"<lane>/<card>/index.md"`, `".trash/<card>/index.md"`.
|
||
public let path: String
|
||
/// The fields that fell back, in schema order. Never empty — a document that read cleanly
|
||
/// contributes no defect at all.
|
||
public let fields: [CoercedField]
|
||
|
||
public init(path: String, fields: [CoercedField]) {
|
||
self.path = path
|
||
self.fields = fields
|
||
}
|
||
}
|
||
|
||
/// 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
|
||
}
|
||
}
|