Files
lanework/Kanban/Storage/IntegrityRules.swift
T
rzen f68ac3668e Comments, phase 1 — storage, writer primitives, and the undo inventory
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
2026-07-30 19:36:21 -04:00

1158 lines
66 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import Foundation
/// **The one pure vocabulary of object validity** — 01-storage-format.md § Validation and healing
/// (settled 2026-07-29), 02-architecture.md ▸ Components ▸ IntegrityRules.
///
/// Every rule in the storage format that refuses, tolerates, recovers or repairs is an instance of
/// one five-verdict taxonomy (`Verdict`), and this type is where the taxonomy's *rules* live: the
/// identity predicate and its canonical form, the reserved-name tables, per-kind index validation,
/// the trash's `kind` discriminator, the on-touch heals, and the typed `Defect` vocabulary the
/// loader reports.
///
/// ### It consolidates rules, it does not relocate enforcement
///
/// **Loader and Writer remain the enforcement points and call in.** The loader still walks and
/// still throws; the Writer still refuses and still writes. What moved here is the *deciding* — so
/// no mechanism re-derives a rule the next one also needs, and adding an object kind (the enhanced
/// schema's `comment`) adds its field table and shape rules in one place rather than a parallel
/// mechanism. A service smeared across the read/write/orchestration boundaries would be worse than
/// the discipline it replaced, which is why nothing here touches a `BoardStore` or schedules
/// anything: the scheduled-heal engine is `HealScheduler`, on the other side of the layering.
///
/// ### Pure
///
/// Every function here is a function of its arguments. Two of them are *about* the filesystem —
/// `placement(ofFolderNamed:inParentNamed:)` and `trashKind(kindValue:hasIdentityShapedChildIndex:)`
/// — and take the facts they need as parameters rather than reading disk themselves, so the rules
/// are pinned by the suite without a filesystem in the way. The one call that does read (the
/// squatter probe, `node(at:)`) is a plain `lstat` classification with no policy in it at all.
public enum IntegrityRules: Sendable {
// MARK: - The five verdicts
/// The taxonomy every detectable defect classifies into — exactly one verdict each, and the
/// verdict fixes everything downstream (surface, write behavior, race posture), so no mechanism
/// ever re-reasons its posture individually (01-storage-format.md § Validation and healing).
///
/// Nothing switches over this today, deliberately: it is the vocabulary the rules below are
/// *written in*, and each rule already names its own verdict at its own site. It exists as a
/// type so that a new rule has to answer "which verdict is this?" before it has anywhere to go.
public enum Verdict: Sendable, Equatable, CaseIterable {
/// Fail-fast — the defect defeats rendering or ordering (`BoardLoadError`).
case refuse
/// Readable-but-uneditable shapes: the file renders fine and every app write to it fails
/// loudly, per file (`FrontmatterDocument.UneditableShape`).
case refuseWrites
/// Outside the schema's claim — strays, symlinks, case-twins, lane- and board-level
/// `deleted:`. Preserved verbatim, logged, never rendered (`LoadWarning`).
case tolerate
/// A sensible reading exists (the coercion rulebook, last-wins, null-as-missing, the rescue
/// family): silent, read-side only, bytes preserved (`FieldValue`).
case coerce
/// An app-owned invariant is violated *and* a lossless canonical repair exists — **the only
/// verdict that writes** (`Defect`, healed inline, on touch, or on schedule).
case heal
}
// MARK: - The identity predicate and its canonical form
/// The hex characters `isIdentityShaped` accepts in each `-`-delimited group — **both cases**,
/// per the shape-only predicate below.
private static let identityGroupCharacters = Set("0123456789abcdefABCDEF")
/// **The identity predicate** — whether `name` has a UUID's shape: hex, `8-4-4-4-12`, **any
/// case and any version** (01-storage-format.md § Fractal layout ▸ Rules, "Name shape gates
/// level detection"). Deliberately shape-only: lowercase v4 is the app's *emission* rule, not
/// the gate.
///
/// - **Any case.** `uuidgen(1)` and `UUID().uuidString` both print uppercase, so a strict
/// lowercase gate would turn an agent's standard-tool card into a silently skipped stray —
/// the worst failure mode for a files-first app. Accept liberally, emit conservatively.
/// - **Any version.** The version and variant nibbles protect no invariant here — an agent's v7
/// is exactly as unique as a v4 — and recognizing the folder-naming *convention* is the job,
/// not re-deriving RFC 4122 conformance on every folder of every load.
///
/// Equivalent to "does `UUID(uuidString:)` parse it", kept as a manual scan because that is the
/// cheaper answer on the hot path and needs no bridging.
///
/// **One rule, one implementation**: `BoardLoader.isUUIDShaped` is this function under the
/// loader's own spelling, and `BoardWriter` reaches it through that. Recognizing a name is not
/// the same as *comparing* two of them — see `canonicalIdentity`.
public static func isIdentityShaped(_ name: String) -> Bool {
let groups = name.split(separator: "-", omittingEmptySubsequences: false)
guard groups.map(\.count) == [8, 4, 4, 4, 12] else { return false }
return groups.allSatisfy { $0.allSatisfy(identityGroupCharacters.contains) }
}
/// **The canonical form of an identity** — a folder name reduced to its UUID *value*.
///
/// Identity comparison is UUID-value equality, never string equality (01-storage-format.md
/// § Fractal layout ▸ Rules, settled): an arriving `55555555-…` and a resident `55555555-…`
/// spelled uppercase are **one** identity. Every identity-shaped name is ASCII hex and hyphens,
/// where locale-independent case folding is UUID-value canonicalization exactly.
///
/// **The one derivation** (settled 2026-07-29 — the fold): `ItemID`'s `==`/`hash(into:)`
/// canonicalize through this function, and so do the Writer's string-level checks, which
/// compare *paths* rather than model values (`BoardWriter.identities(inBoard:)`, the
/// import-boundary collision probe, `freshUUIDName`'s `taken` set). The Writer's former private
/// `canonicalIdentity` was a second copy of this one line; a second copy of a rule this
/// load-bearing is a bug waiting for the day the two disagree.
///
/// Total on any string: off-shape input compares by its own lowercasing, which is the harmless
/// reading (`ItemID` stays total for the same reason).
public static func canonicalIdentity(_ name: String) -> String {
name.lowercased()
}
// MARK: - The reserved-name tables
/// The board's trash container (01-storage-format.md § Deletion) — a **directory** name.
public static let trashFolderName = ".trash"
/// A card's attachment folder (01-storage-format.md § Attachments) — the one folder the app
/// ever creates under a card. **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 13 are the *earlier-occurrence-wins* rule; rung 0 is not about age at all, which is why
/// it sits outside and above it.
///
/// Pure, like everything here: the container, the birth dates and the history ranks are read by
/// the loader and arrive as values, so the whole rule is pinned by the suite without a filesystem
/// or a repo in the way.
public static func dedupe(_ occurrences: [IdentityOccurrence]) -> DedupeVerdict {
// Grouped by identity, first-seen order preserved — determinism starts here, because a
// dictionary's own iteration order is not one.
var members: [String: [Int]] = [:]
var identities: [String] = []
for (index, occurrence) in occurrences.enumerated() {
let identity = occurrence.identity
if members[identity] == nil { identities.append(identity) }
members[identity, default: []].append(index)
}
var duplicates: [(index: Int, work: DuplicateIdentity)] = []
var caseTwins: [(index: Int, work: CaseTwin)] = []
for identity in identities {
guard let group = members[identity], group.count > 1 else { continue }
// 1. The winning *spelling*, under the container preference first: a spelling some live
// occurrence carries outranks one only trash ghosts carry, and the canonical-else-
// lexicographic rule then decides among the candidates. A wholly trashed group has no
// live candidates and falls through to all of them, unchanged. `identity` is the
// all-lowercase form by construction, so "is the canonical spelling present" is one
// membership test either way.
let spellings = Set(group.map { occurrences[$0].name })
let liveSpellings = Set(
group.lazy.filter { occurrences[$0].container == .live }.map { occurrences[$0].name }
)
let candidates = liveSpellings.isEmpty ? spellings : liveSpellings
let canonical = candidates.contains(identity) ? identity : candidates.sorted()[0]
// 2. Earlier-occurrence-wins among the canonical spelling's occurrences. `sorted` is not
// guaranteed stable, so the traversal index is the comparator's own last rung rather
// than something left to the sort.
let contenders = group.filter { occurrences[$0].name == canonical }
let ranked = contenders.sorted { entered(occurrences[$0], at: $0, before: occurrences[$1], at: $1) }
let winner = occurrences[ranked[0]].path
for index in group where occurrences[index].name != canonical {
caseTwins.append((index, CaseTwin(path: occurrences[index].path, winner: winner)))
}
for index in ranked.dropFirst() {
duplicates.append((index, DuplicateIdentity(
path: occurrences[index].path,
identity: identity,
title: occurrences[index].title,
winner: winner
)))
}
}
// Traversal order across groups too: the notice's subjects and the log's lines read in the
// order the board is laid out, not in the order a dictionary happened to hand out identities.
return DedupeVerdict(
duplicates: duplicates.sorted { $0.index < $1.index }.map(\.work),
caseTwins: caseTwins.sorted { $0.index < $1.index }.map(\.work)
)
}
/// The precedence comparator — the four-rung ladder above, and the whole of the winner rule.
///
/// Named for its majority (`entered … before …` is earlier-occurrence-wins' own phrasing) even
/// though rung 0 is not about entry order at all: the container preference is stated as *the first
/// tie-break*, so it belongs in the one comparator rather than as a pre-partition the callers of
/// this rule would each have to remember.
private static func entered(
_ lhs: IdentityOccurrence,
at lhsIndex: Int,
before rhs: IdentityOccurrence,
at rhsIndex: Int
) -> Bool {
// 0. The container boundary, ahead of everything: the visible card never loses to its own
// ghost, however much older or better-tracked the ghost is.
if lhs.container != rhs.container { return lhs.container == .live }
switch (lhs.historyRank, rhs.historyRank) {
case let (left?, right?):
// Both tracked: the path that entered history first.
if left != right { return left < right }
case (.some, .none):
// "The path history already tracks outranks the newcomer" — read literally.
return true
case (.none, .some):
return false
case (.none, .none):
break
}
if let left = lhs.birth, let right = rhs.birth, left != right { return left < right }
return lhsIndex < rhsIndex
}
}
// MARK: - The defect payloads
/// One card found holding files that belong in its `attachments/` — everything the relocation and
/// its notice need, and nothing more (01-storage-format.md § Fractal layout ▸ Rules, settled
/// 2026-07-28, "Lanework-owns-the-board").
///
/// `title` is the card's as written, `nil` for an untitled one: "Untitled" is a rendering, never a
/// value (03-board-ui.md § Card face), so the phrasing layer decides what to call it. The path is
/// carried as its two identity components rather than as a URL, `ItemPath`'s convention, so the
/// write derives its path from the store's *current* root.
public struct LooseCardFiles: Sendable, Equatable {
public let laneID: ItemID
public let cardID: ItemID
public let title: String?
/// The loose files' names, in Finder order (`BoardLoader.looseFileNames`). Never empty — a card
/// with nothing loose contributes no defect at all.
public let fileNames: [String]
public init(laneID: ItemID, cardID: ItemID, title: String?, fileNames: [String]) {
self.laneID = laneID
self.cardID = cardID
self.title = title
self.fileNames = fileNames
}
}
/// One **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
}
}