The 2026-07-29 integrity design pass, consolidated (DESIGN/01 -
Validation and healing; DESIGN/02 - Components): IntegrityRules
(Storage, pure) is the one home for the identity predicate and
canonical form (BoardWriter.canonicalIdentity deleted, ItemID and the
loader forward to it), the per-field rulebook, uneditable shapes,
per-kind index validation, the reserved-name tables, and the trash
kind discriminator (values trusted - kind: lane/card explicit,
unrecognized falls to shape). LoadResult's ad-hoc channels fold into
one typed Defect stream (looseCardFiles / legacyTombstone /
claimedNameSquatted, per-defect heal signatures); the old accessors
survive as computed views.
HealScheduler (LiveStore) states the six-step heal pattern once -
resting-clear, lock gate, isWritableFile gate (now covering all four
heals), signature memo armed-before-attempt with explicit
clear-on-success, disk re-verify in each write half, one banner-posture
table (BannerCenter keeps all phrasing). The three hand-rolled healers
run on it with behavior preserved - including the
relocation-notice-despite-partial-failure quirk, deliberately. Heals
run at the reload tail AND at registry acquire, closing the
migration-never-fires-at-open asymmetry. Displacement runs first: a
squatted .trash would otherwise fail the migration and arm its memo
against an unchanged picture.
Claimed-name squatters (ruled today, 62c47a2) displace by the shared
Finder-style rename ladder - preserved verbatim, symlinks moved as
links, nothing stamped; AgentGuide's untouchable-skip upgrades to
displace-then-write, the CLAUDE.user.md-taken skip stands. kind stamps
on every create and backfills on any index rewrite via the on-touch
seam (placement resolver stamps nothing when the parent is unknown -
a guessed kind is worse than an absent one; board-root writers declare
theirs). Heal writes mark their EchoLedger receipts (inert in base;
pro-m1's committer will split them into their own commits). The
renumber ask-renumber-ask-again two-step is one shared helper, adopted
at all nine call sites.
69 tests added. 1738 green on both schemes.
Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
583 lines
31 KiB
Swift
583 lines
31 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.
|
|
public static let attachmentsFolderName = "attachments"
|
|
|
|
/// The file every level's content lives in.
|
|
public static let indexFileName = "index.md"
|
|
|
|
/// **The card-level reserved names** (01-storage-format.md § Fractal layout ▸ Rules): the
|
|
/// card's own `index.md` plus the two reserved children. `comments` is listed because the schema
|
|
/// reserves the name, not because anything writes it yet.
|
|
///
|
|
/// **Compared lowercased**, because the filesystem this runs on usually is: a file spelled
|
|
/// `Index.md` *is* the card's index to `fileExists`, and a case-sensitive reservation check
|
|
/// would hand the loose-file relocation a card's own content to move into `attachments/`.
|
|
public static let reservedCardChildNames: Set<String> = [
|
|
indexFileName, attachmentsFolderName, "comments",
|
|
]
|
|
|
|
/// What kind of node a name is allowed to be.
|
|
///
|
|
/// `String`-backed so a defect's signature has a stable token to spell (`description` is prose
|
|
/// for a log line and must stay free to change without re-arming a memo).
|
|
public enum NodeKind: String, Sendable, Equatable, CustomStringConvertible {
|
|
case file
|
|
case directory
|
|
/// Never followed, never traversed, never resolved — `lstat` semantics everywhere in this
|
|
/// app (01-storage-format.md § Fractal layout ▸ Rules). A symlink is a *node that is there*,
|
|
/// whatever it points at, which is why it is its own case rather than the type of its
|
|
/// target.
|
|
case symlink
|
|
|
|
public var description: String {
|
|
switch self {
|
|
case .file: "a file"
|
|
case .directory: "a folder"
|
|
case .symlink: "a symbolic link"
|
|
}
|
|
}
|
|
}
|
|
|
|
/// **A board-root name the app claims** — the one scope on the verbatim promise
|
|
/// (01-storage-format.md § Fractal layout ▸ Rules: "Three board-root names are app-claimed, not
|
|
/// strays", plus `.trash/` from § Deletion).
|
|
public struct ClaimedName: Sendable, Equatable {
|
|
public let name: String
|
|
/// What the app needs the name to *be*. A node of any other kind is not a resident — it is
|
|
/// an invalid artifact on a name Lanework owns.
|
|
public let expected: NodeKind
|
|
/// Whether a wrong-kinded node on this name is displaced by the scheduled heal (ruled
|
|
/// 2026-07-29 — "Lanework owns the board"), or left exactly where it is.
|
|
///
|
|
/// `false` for the two names that are *destinations* or not the app's to police:
|
|
/// `CLAUDE.user.md` is where a markerless `CLAUDE.md` is rescued **to**, and freeing a
|
|
/// destination by a second displacement would cascade renames (the settled skip stands —
|
|
/// 08-agent-integration.md); `.gitignore` is seeded once and then the user's to edit
|
|
/// (06-history-undo.md ▸ Repository hygiene), and nothing in the app reads it.
|
|
public let displacesSquatters: Bool
|
|
}
|
|
|
|
/// The claimed-name table — **one place**, where these names were scattered across the loader,
|
|
/// the guide, and the trash writer before (02-architecture.md ▸ Components: "the reserved-name
|
|
/// tables … today scattered").
|
|
///
|
|
/// `CLAUDE.md`'s squatter is displaced by the **agent guide's** own heal, which already owns
|
|
/// that file's whole decision (`AgentGuide.Decision.displaceSquatterThenWrite`); `.trash`'s is
|
|
/// its own scheduled heal, because nothing else ever writes that name.
|
|
public static let claimedRootNames: [ClaimedName] = [
|
|
ClaimedName(name: trashFolderName, expected: .directory, displacesSquatters: true),
|
|
ClaimedName(name: "CLAUDE.md", expected: .file, displacesSquatters: true),
|
|
ClaimedName(name: "CLAUDE.user.md", expected: .file, displacesSquatters: false),
|
|
ClaimedName(name: ".gitignore", expected: .file, displacesSquatters: false),
|
|
]
|
|
|
|
/// The claimed names as the lane walk needs them: lowercased, for a `contains` against a
|
|
/// directory entry. Compared lowercased for `reservedCardChildNames`' reason.
|
|
public static let claimedRootNameSet: Set<String> = Set(claimedRootNames.map { $0.name.lowercased() })
|
|
|
|
/// What is sitting at `url`, by `lstat` and nothing else — `nil` when nothing is there.
|
|
///
|
|
/// **`attributesOfItem`, never `fileExists`**: a dangling symlink is a node that is *there*
|
|
/// (a move onto it would fail, and this app does not touch symlinks anyway), while `fileExists`
|
|
/// follows the link, finds nothing, and would call the name free.
|
|
public static func node(at url: URL) -> NodeKind? {
|
|
guard let type = (try? FileManager.default.attributesOfItem(atPath: url.path))?[.type]
|
|
as? FileAttributeType
|
|
else {
|
|
return nil
|
|
}
|
|
switch type {
|
|
case .typeSymbolicLink: return .symlink
|
|
case .typeDirectory: return .directory
|
|
default: return .file
|
|
}
|
|
}
|
|
|
|
/// The claimed-name defect at a board root, or `nil` when every claimed name is free or held by
|
|
/// the right kind of node — the **detection** half of the squatter-displacement ruling
|
|
/// (01-storage-format.md § Fractal layout ▸ Rules, ruled 2026-07-29).
|
|
///
|
|
/// Only names whose `displacesSquatters` is `true` can produce one, and only `.trash` is
|
|
/// answered here: `CLAUDE.md`'s squatter is the agent guide's, detected by `AgentGuide.inspect`
|
|
/// in the same read that decides everything else about that file, so answering it twice would be
|
|
/// two mechanisms racing to displace one node.
|
|
public static func squattedClaimedName(atBoardRoot root: URL) -> ClaimedNameSquatter? {
|
|
guard let claimed = claimedRootNames.first(where: { $0.name == trashFolderName }),
|
|
let found = node(at: root.appendingPathComponent(claimed.name)),
|
|
found != claimed.expected
|
|
else {
|
|
return nil
|
|
}
|
|
return ClaimedNameSquatter(name: claimed.name, found: found, expected: claimed.expected)
|
|
}
|
|
|
|
// MARK: - Object kinds
|
|
|
|
/// The kinds the schema knows (01-storage-format.md § Frontmatter ▸ Common to all levels, the
|
|
/// `kind` row). `comment` is reserved with the enhanced schema and deliberately absent until it
|
|
/// lands — an unrecognized value on disk is trusted as itself and never policed, so nothing here
|
|
/// has to anticipate it.
|
|
public enum ObjectKind: String, Sendable, Equatable, CaseIterable {
|
|
case board
|
|
case lane
|
|
case card
|
|
}
|
|
|
|
/// Where a folder's **position** places it — "level is position" (01-storage-format.md
|
|
/// § Fractal layout) as a value, decided from two names and nothing else.
|
|
public enum Placement: Sendable, Equatable {
|
|
/// Its parent is identity-shaped, so it is a card: a lane's children are the only
|
|
/// identity-bearing folders under an identity-bearing folder.
|
|
case card
|
|
/// Identity-shaped, under something that is neither a lane nor the trash — a lane.
|
|
case lane
|
|
/// Inside `.trash/`, where the container is flat and **position cannot answer**: use
|
|
/// `trashKind(kindValue:hasIdentityShapedChildIndex:)`.
|
|
case insideTrash
|
|
/// Position says nothing. A board root reaches this (its folder name is a Finder document
|
|
/// name, not an identity), and so does any hand-named folder — which is why the answer is
|
|
/// "unknown" rather than "board": guessing here would stamp `kind: board` onto whatever a
|
|
/// caller happened to point at.
|
|
case unknown
|
|
}
|
|
|
|
/// The placement rule, in the order the questions can be answered.
|
|
///
|
|
/// The trash check sits *between* the two identity checks deliberately: a trashed card and a
|
|
/// live lane are both identity-shaped folders whose parent is not, and the container is the only
|
|
/// thing that tells them apart.
|
|
public static func placement(ofFolderNamed name: String, inParentNamed parent: String) -> Placement {
|
|
if isIdentityShaped(parent) { return .card }
|
|
if parent.lowercased() == trashFolderName { return .insideTrash }
|
|
if isIdentityShaped(name) { return .lane }
|
|
return .unknown
|
|
}
|
|
|
|
/// **The trash's `kind` discriminator** (01-storage-format.md § Deletion, re-ruled 2026-07-29 —
|
|
/// the value-names-the-kind posture): depth defines meaning on the live board, but the trash is
|
|
/// flat, and an empty lane folder is shape-identical to a card folder.
|
|
///
|
|
/// **The value is trusted outright** — `kind: lane` → lane, `kind: card` → card — so an external
|
|
/// writer's `kind: lane` on what looks card-shaped is honored, never policed and never
|
|
/// corroborated. An unrecognized value or no key at all falls through to **shape**:
|
|
/// identity-shaped children with their own `index.md` → lane (the key backfills on the next
|
|
/// touch), else card.
|
|
///
|
|
/// `kind: board` in the trash is *not* a third answer: a board is not a thing that can be
|
|
/// trashed, so the value is unrecognized here and shape decides — the same shrug an arbitrary
|
|
/// string gets.
|
|
///
|
|
/// The shape half is `@autoclosure` so that the rule stays a pure function of two facts while
|
|
/// its caller pays for the directory listing **only when the value did not answer** — which on
|
|
/// a board written by this app is never.
|
|
public static func trashKind(
|
|
kindValue: String?,
|
|
hasIdentityShapedChildIndex: @autoclosure () -> Bool
|
|
) -> ObjectKind {
|
|
switch kindValue.flatMap(ObjectKind.init(rawValue:)) {
|
|
case .lane: return .lane
|
|
case .card: return .card
|
|
case .board, nil: return hasIdentityShapedChildIndex() ? .lane : .card
|
|
}
|
|
}
|
|
|
|
// MARK: - Per-field validation (the rulebook)
|
|
|
|
/// `schema`, validated: present, well-formed, not newer than this app (01-storage-format.md
|
|
/// § Malformed input). Required at every level.
|
|
public static func validatedSchema(
|
|
in document: FrontmatterDocument,
|
|
path: String,
|
|
supportedSchema: Int
|
|
) throws(BoardLoadError) -> Int {
|
|
switch document.schema {
|
|
case .missing:
|
|
throw BoardLoadError(path: path, reason: .missingSchema)
|
|
case let .malformed(raw):
|
|
throw BoardLoadError(path: path, reason: .malformedSchema(raw: raw))
|
|
case let .valid(value):
|
|
guard value <= supportedSchema else {
|
|
throw BoardLoadError(path: path, reason: .schemaNewerThanApp(found: value))
|
|
}
|
|
return value
|
|
}
|
|
}
|
|
|
|
/// `order`, validated: present and well-formed. Required on lanes and cards, **never** on the
|
|
/// board itself — which is the whole of the per-kind difference in the tables today.
|
|
public static func validatedOrder(
|
|
in document: FrontmatterDocument,
|
|
path: String
|
|
) throws(BoardLoadError) -> Double {
|
|
switch document.order {
|
|
case .missing:
|
|
throw BoardLoadError(path: path, reason: .missingOrder)
|
|
case let .malformed(raw):
|
|
throw BoardLoadError(path: path, reason: .malformedOrder(raw: raw))
|
|
case let .valid(value):
|
|
return value
|
|
}
|
|
}
|
|
|
|
/// Whether an object of `kind` must carry `order` — the per-kind field table, as a rule rather
|
|
/// than as two hand-written call sites in the loader's walk.
|
|
public static func requiresOrder(_ kind: ObjectKind) -> Bool {
|
|
switch kind {
|
|
case .board: false
|
|
case .lane, .card: true
|
|
}
|
|
}
|
|
|
|
/// **Per-kind index validation** — the loader's own checks, in its own order, over bytes that
|
|
/// need not be on disk yet (02-architecture.md ▸ Components: "the card validator generalized per
|
|
/// kind — board, lane, card, the enhanced schema's comment when it lands").
|
|
///
|
|
/// Exactly the checks `BoardLoader.load` runs on an object of that kind, through its own
|
|
/// functions: decode + parse, then `schema`, then `order` where the kind requires it. Nothing
|
|
/// further is checked, because nothing else *is*: `title` is optional, unknown keys are the
|
|
/// point of the outlet the card validator serves, and the body is free text.
|
|
///
|
|
/// It deliberately does **not** check `uneditableShape`: that refusal exists for surgical span
|
|
/// edits, and the raw-source Apply this serves replaces the whole file — a flow-mapping
|
|
/// frontmatter is precisely one of the things the escape hatch exists to let a user rewrite.
|
|
public static func validateIndex(
|
|
_ data: Data,
|
|
path: String,
|
|
kind: ObjectKind,
|
|
supportedSchema: Int
|
|
) throws(BoardLoadError) -> FrontmatterDocument {
|
|
let document = try BoardLoader.parseDocument(data, path: path)
|
|
_ = try validatedSchema(in: document, path: path, supportedSchema: supportedSchema)
|
|
if requiresOrder(kind) {
|
|
_ = try validatedOrder(in: document, path: path)
|
|
}
|
|
return document
|
|
}
|
|
|
|
/// Why this document refuses every app write, or `nil` when it can be edited in place — the
|
|
/// **refuse-writes** verdict's whole rule (01-storage-format.md § Frontmatter, the
|
|
/// readable-but-uneditable shapes).
|
|
///
|
|
/// The analysis itself lives on `FrontmatterDocument`, computed at parse from the span
|
|
/// structure it alone has; naming it here is what puts the verdict in the vocabulary rather than
|
|
/// leaving it as a property one call site happens to read.
|
|
public static func uneditableShape(of document: FrontmatterDocument) -> FrontmatterDocument.UneditableShape? {
|
|
document.uneditableShape
|
|
}
|
|
|
|
// MARK: - On-touch heals
|
|
|
|
/// A latent defect fixed by folding into a write that is **already rewriting that file**
|
|
/// (01-storage-format.md § Validation and healing: "on-touch when the defect is latent"). Never
|
|
/// a scheduled sweep, never a write of its own — an on-touch heal rides its host write's single
|
|
/// atomic rewrite and its host's `modified` stamp, and composes no event of its own.
|
|
public enum OnTouchHeal: Sendable, Equatable {
|
|
/// `kind` was missing and has been stamped with the object's own kind (re-ruled 2026-07-29
|
|
/// — the common-schema row). **On-touch only, never a scheduled backfill sweep**: the key is
|
|
/// consequential only inside `.trash/`, and a sweep would rewrite every file on the board to
|
|
/// add a key that is redundant with position everywhere else.
|
|
case kindBackfilled(ObjectKind)
|
|
|
|
/// A key written twice collapsed to its winning (last) occurrence — the span editor's
|
|
/// duplicate-key twin removal (`FrontmatterDocument.set`). Named here because it *is* an
|
|
/// on-touch heal and was only ever documented as an editing detail: last-wins is the read
|
|
/// rule, and the write that touches the key is where the twins stop being able to resurrect.
|
|
case duplicateKeyTwinsRemoved(key: String)
|
|
|
|
/// A value that needed quoting got it on its first app write — the colon rescue's
|
|
/// quote-on-first-write (`FrontmatterValue.emitScalar`). The same class as the twin removal
|
|
/// and named for the same reason: the app writes the value correctly the first time it has
|
|
/// any reason to write it at all, and never on a file it was not already rewriting.
|
|
case quotedOnFirstWrite(key: String)
|
|
}
|
|
|
|
/// **The on-touch heal seam**: the pending latent work on the document a write is already
|
|
/// rewriting, applied (02-architecture.md ▸ Components: "on-touch heals live at the Writer's
|
|
/// `updateIndex` seam, which consults IntegrityRules for pending on-touch work on the file it is
|
|
/// rewriting").
|
|
///
|
|
/// Only the `kind` backfill is applied *here*; the other two members of the class are applied by
|
|
/// `FrontmatterDocument`'s own editor on every key it writes, and are named in `OnTouchHeal`
|
|
/// rather than re-implemented. That is the honest shape of "the same class, named, no behavior
|
|
/// change".
|
|
///
|
|
/// - **Missing only.** A present `kind` is never rewritten, never corroborated, and never
|
|
/// stripped — the value names the kind and consumers trust it outright. An explicit `kind:`
|
|
/// with nothing after it reads as missing, like every other null (the null-as-missing rule),
|
|
/// and so backfills.
|
|
/// - **`kind == nil` stamps nothing.** Position cannot always answer (`Placement.unknown`), and
|
|
/// a guessed kind written to disk would be worse than an absent one: the trash discriminator
|
|
/// trusts what it finds.
|
|
/// - Parameter kind: `@autoclosure` so a caller whose answer costs a directory listing (the
|
|
/// trash's shape fallback) pays for it only on a file that actually needs the backfill.
|
|
@discardableResult
|
|
public static func healOnTouch(
|
|
_ document: inout FrontmatterDocument,
|
|
kind: @autoclosure () -> ObjectKind?
|
|
) -> [OnTouchHeal] {
|
|
guard document.kind.isMissing, let kind = kind() else { return [] }
|
|
document.set(FrontmatterKeys.kind, to: .string(kind.rawValue))
|
|
return [.kindBackfilled(kind)]
|
|
}
|
|
|
|
// MARK: - The typed defect stream
|
|
|
|
/// **One defect, typed** — what the loader reports as pending *work*, as distinct from the
|
|
/// tolerate-tier `LoadWarning`s it reports as information (02-architecture.md ▸ Components:
|
|
/// "`LoadResult`'s ad-hoc repair channels (loose files, legacy tombstones) become one typed
|
|
/// defect stream; tolerate-tier warnings stay warnings").
|
|
///
|
|
/// The distinction is the whole reason the two channels are not one: a warning says "this was
|
|
/// ignored, it is staying exactly where it is, there is nothing to do", and a defect says the
|
|
/// opposite. Folding defects into the warning channel would also throw away everything the heal
|
|
/// needs (which lane, which card, which title, which names) and force it to be re-derived from a
|
|
/// display string.
|
|
public enum Defect: Sendable, Equatable {
|
|
/// A card holding files that belong in its `attachments/` (the loose-file carve-out).
|
|
case looseCardFiles(LooseCardFiles)
|
|
/// An item carrying a legacy `deleted:` key (the retired tombstone model's migration input).
|
|
case legacyTombstone(LegacyTombstone)
|
|
/// A claimed board-root name held by the wrong kind of node (ruled 2026-07-29).
|
|
case claimedNameSquatted(ClaimedNameSquatter)
|
|
|
|
/// The scheduled-heal classes, which are also the engine's memo keys and its
|
|
/// banner-posture rows (`HealScheduler`).
|
|
///
|
|
/// `staleAgentGuide` has no `Defect` case, and that asymmetry is honest rather than an
|
|
/// oversight: the guide's defect is a property of one board-root file's *version marker*,
|
|
/// read at the moment of healing (`AgentGuide.inspect`), not something a tree walk reports.
|
|
/// It is a class here because the engine treats it exactly like the others — same gates,
|
|
/// same memo, same clear-on-success.
|
|
public enum Class: Sendable, Equatable, Hashable, CaseIterable {
|
|
case looseCardFiles
|
|
case legacyTombstone
|
|
case claimedNameSquatted
|
|
case staleAgentGuide
|
|
}
|
|
|
|
public var healClass: Class {
|
|
switch self {
|
|
case .looseCardFiles: .looseCardFiles
|
|
case .legacyTombstone: .legacyTombstone
|
|
case .claimedNameSquatted: .claimedNameSquatted
|
|
}
|
|
}
|
|
|
|
/// **The defect's identity as a comparable string** — the memo's unit (01-storage-format.md
|
|
/// § Validation and healing: "re-armed only by a changed defect signature").
|
|
///
|
|
/// What matters is the *identity* of the work, never the order the walk happened to meet it
|
|
/// in, which is why the engine compares sets of these rather than arrays of defects: two
|
|
/// loads of an unchanged tree must compare equal even if a lane's folder-name ordering
|
|
/// shifted underneath them.
|
|
public var signatures: [String] {
|
|
switch self {
|
|
case let .looseCardFiles(work):
|
|
work.fileNames.map { "loose:\(work.laneID.rawValue)/\(work.cardID.rawValue)/\($0)" }
|
|
case let .legacyTombstone(work):
|
|
["tombstone:\(work.laneID.rawValue)/\(work.cardID?.rawValue ?? "")"]
|
|
case let .claimedNameSquatted(work):
|
|
// The node *kind* is part of the picture: a squatter replaced by a different kind
|
|
// of squatter is a new defect, and a heal that failed on one has no claim to have
|
|
// failed on the other.
|
|
["claimed:\(work.name):\(work.found.rawValue)"]
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - The defect payloads
|
|
|
|
/// One card found holding files that belong in its `attachments/` — everything the relocation and
|
|
/// its notice need, and nothing more (01-storage-format.md § Fractal layout ▸ Rules, settled
|
|
/// 2026-07-28, "Lanework-owns-the-board").
|
|
///
|
|
/// `title` is the card's as written, `nil` for an untitled one: "Untitled" is a rendering, never a
|
|
/// value (03-board-ui.md § Card face), so the phrasing layer decides what to call it. The path is
|
|
/// carried as its two identity components rather than as a URL, `ItemPath`'s convention, so the
|
|
/// write derives its path from the store's *current* root.
|
|
public struct LooseCardFiles: Sendable, Equatable {
|
|
public let laneID: ItemID
|
|
public let cardID: ItemID
|
|
public let title: String?
|
|
/// The loose files' names, in Finder order (`BoardLoader.looseFileNames`). Never empty — a card
|
|
/// with nothing loose contributes no defect at all.
|
|
public let fileNames: [String]
|
|
|
|
public init(laneID: ItemID, cardID: ItemID, title: String?, fileNames: [String]) {
|
|
self.laneID = laneID
|
|
self.cardID = cardID
|
|
self.title = title
|
|
self.fileNames = fileNames
|
|
}
|
|
}
|
|
|
|
/// One item found carrying a legacy `deleted:` key — everything its migration and notice need, and
|
|
/// nothing more (01-storage-format.md § Deletion: "Legacy `deleted:` keys migrate on load-and-write,
|
|
/// never destroy").
|
|
///
|
|
/// The path is carried as its identity components rather than as a URL — `LooseCardFiles`'
|
|
/// convention, for its reason. `title` is the item's as written, `nil` for an untitled one.
|
|
public struct LegacyTombstone: Sendable, Equatable {
|
|
/// Which migration this item takes — the two are genuinely different acts, not one act at two
|
|
/// levels: a card *moves* (into `.trash/`, at a minted top-of-trash rank) and a lane stays
|
|
/// exactly where it is (the key is stripped and it returns live).
|
|
public enum Kind: Sendable, Equatable {
|
|
case card
|
|
case lane
|
|
}
|
|
|
|
public let kind: Kind
|
|
|
|
/// The lane's own identity for `.lane`; the card's **containing** lane for `.card` — the context
|
|
/// the relocation needs to find the folder at all.
|
|
public let laneID: ItemID
|
|
|
|
/// The card's identity for `.card`, `nil` for `.lane`. Two fields rather than an enum payload so
|
|
/// the common "which folder is this" question is one path join at every call site.
|
|
public let cardID: ItemID?
|
|
|
|
public let title: String?
|
|
|
|
public init(kind: Kind, laneID: ItemID, cardID: ItemID?, title: String?) {
|
|
self.kind = kind
|
|
self.laneID = laneID
|
|
self.cardID = cardID
|
|
self.title = title
|
|
}
|
|
}
|
|
|
|
/// A claimed board-root name held by the wrong kind of node — a file or symlink squatting `.trash`,
|
|
/// a directory or symlink squatting `CLAUDE.md` (01-storage-format.md § Fractal layout ▸ Rules,
|
|
/// ruled 2026-07-29: "A claimed name held by the wrong kind of node is Lanework's to heal — by
|
|
/// displacement").
|
|
///
|
|
/// **An invalid artifact, not a resident.** The heal moves it aside via the Finder-style rename
|
|
/// ladder (`.trash` → `.trash 2`), preserved verbatim and never destroyed, with a warning-tone
|
|
/// notice naming old and new — the invariant that survives is displacement-never-destruction.
|
|
public struct ClaimedNameSquatter: Sendable, Equatable {
|
|
/// The claimed name, exactly as the app spells it (`.trash`, `CLAUDE.md`).
|
|
public let name: String
|
|
/// What is actually sitting there — never followed if it is a symlink.
|
|
public let found: IntegrityRules.NodeKind
|
|
/// What the app needs the name to be.
|
|
public let expected: IntegrityRules.NodeKind
|
|
|
|
public init(name: String, found: IntegrityRules.NodeKind, expected: IntegrityRules.NodeKind) {
|
|
self.name = name
|
|
self.found = found
|
|
self.expected = expected
|
|
}
|
|
}
|