Build the integrity service - IntegrityRules and the HealScheduler
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
This commit is contained in:
+133
-159
@@ -91,7 +91,10 @@ public enum BoardLoader: Sendable {
|
||||
|
||||
/// Internal rather than `private`: `BoardWriter` names the same file, and the loader and
|
||||
/// the writer must never disagree about which file a folder's content lives in.
|
||||
static let indexFileName = "index.md"
|
||||
///
|
||||
/// The name itself is `IntegrityRules`', with every other reserved name — one table
|
||||
/// (02-architecture.md ▸ Components).
|
||||
static let indexFileName = IntegrityRules.indexFileName
|
||||
|
||||
/// The materialized trash container at board root (01-storage-format.md § Deletion, resettled
|
||||
/// 2026-07-28) — **app-claimed, never a stray**, joining `CLAUDE.md`, `CLAUDE.user.md` and the
|
||||
@@ -103,8 +106,9 @@ public enum BoardLoader: Sendable {
|
||||
/// (`reservedRootNames`) so the rule holds even where hidden-file semantics don't.
|
||||
///
|
||||
/// Internal rather than `private`: `BoardWriter` moves folders into and out of this exact
|
||||
/// name, and a board can have only one trash.
|
||||
static let trashFolderName = ".trash"
|
||||
/// name, and a board can have only one trash. The name is `IntegrityRules`', with the rest of
|
||||
/// the claimed-name table.
|
||||
static let trashFolderName = IntegrityRules.trashFolderName
|
||||
|
||||
/// Board-root names the app claims, and therefore the names the lane walk skips **without a
|
||||
/// stray warning** (01-storage-format.md § Fractal layout ▸ Rules: "Three board-root names are
|
||||
@@ -114,9 +118,10 @@ public enum BoardLoader: Sendable {
|
||||
/// files are listed because the claim is about names, and a future check that needs the set
|
||||
/// should find it complete rather than build a second one. Compared lowercased, like
|
||||
/// `reservedCardChildNames` and for its reason — the filesystem this runs on usually is.
|
||||
static let reservedRootNames: Set<String> = [
|
||||
trashFolderName, "claude.md", "claude.user.md", ".gitignore",
|
||||
]
|
||||
///
|
||||
/// The table is `IntegrityRules.claimedRootNames`, which also carries what kind of node each
|
||||
/// name is allowed to be — the fact the squatter-displacement heal turns on (ruled 2026-07-29).
|
||||
static let reservedRootNames: Set<String> = IntegrityRules.claimedRootNameSet
|
||||
|
||||
/// The card-level names the app claims, and therefore the three the loose-file carve-out
|
||||
/// never touches (01-storage-format.md § Fractal layout ▸ Rules: "Reserved card-level names
|
||||
@@ -129,10 +134,9 @@ public enum BoardLoader: Sendable {
|
||||
/// would hand the loose-file relocation a card's own content to move into `attachments/`.
|
||||
///
|
||||
/// Internal rather than `private`: `BoardWriter.relocateLooseFiles` refuses the same three
|
||||
/// names on its own, so a caller passing a hand-made list cannot reach past this rule.
|
||||
static let reservedCardChildNames: Set<String> = [
|
||||
indexFileName, BoardWriter.attachmentsFolderName, "comments",
|
||||
]
|
||||
/// names on its own, so a caller passing a hand-made list cannot reach past this rule. The
|
||||
/// table itself is `IntegrityRules`', with every other reserved name.
|
||||
static let reservedCardChildNames: Set<String> = IntegrityRules.reservedCardChildNames
|
||||
|
||||
private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "loader")
|
||||
|
||||
@@ -154,13 +158,22 @@ public enum BoardLoader: Sendable {
|
||||
logger.warning("\(warning.description, privacy: .public)")
|
||||
}
|
||||
|
||||
// The carve-out's detection channel — deliberately *not* `warnings`, which is the
|
||||
// stray-*tolerance* vocabulary (see `LoadResult.looseCardFiles`).
|
||||
var looseCardFiles: [LooseCardFiles] = []
|
||||
// **The one typed defect stream** (02-architecture.md ▸ Components ▸ IntegrityRules): what
|
||||
// this walk found that is pending *work*, as distinct from `warnings`, which is the
|
||||
// stray-*tolerance* vocabulary — information, not work. The two ad-hoc repair channels this
|
||||
// replaced (loose files, legacy tombstones) are still readable under their own names as
|
||||
// views over it (`LoadResult.looseCardFiles`, `.legacyTombstones`).
|
||||
var defects: [IntegrityRules.Defect] = []
|
||||
|
||||
// The retired tombstone model's detection channel, on the same reasoning and in the same
|
||||
// idiom (see `LoadResult.legacyTombstones`).
|
||||
var legacyTombstones: [LegacyTombstone] = []
|
||||
// Detected before the walk, so a board whose `.trash` is squatted reports it even though
|
||||
// the trash read below finds nothing to parse. Read-only here, like every other detection:
|
||||
// the displacement is the store's, through the Writer (the Repair precedent).
|
||||
if let squatter = IntegrityRules.squattedClaimedName(atBoardRoot: boardRoot) {
|
||||
defects.append(.claimedNameSquatted(squatter))
|
||||
logger.warning(
|
||||
"\(squatter.name, privacy: .public): claimed name held by \(squatter.found.description, privacy: .public) — to be displaced"
|
||||
)
|
||||
}
|
||||
|
||||
// Legal per the frontmatter table, meaningless at board level — ignore and log, never
|
||||
// tombstone, and **never migrate**: "a `deleted:` key at board level remains meaningless
|
||||
@@ -210,24 +223,24 @@ public enum BoardLoader: Sendable {
|
||||
// Noticed, never acted on: the relocation is the store's, through the Writer.
|
||||
let loose = looseFileNames(in: cardURL)
|
||||
if !loose.isEmpty {
|
||||
looseCardFiles.append(LooseCardFiles(
|
||||
defects.append(.looseCardFiles(LooseCardFiles(
|
||||
laneID: ItemID(rawValue: laneName),
|
||||
cardID: ItemID(rawValue: cardName),
|
||||
title: card.title.value,
|
||||
fileNames: loose
|
||||
))
|
||||
)))
|
||||
logger.info("\(cardRelPath, privacy: .public): \(loose.count, privacy: .public) loose file(s) beside index.md — to be relocated into attachments/")
|
||||
}
|
||||
|
||||
// Detection only, the loose-file precedent exactly: the relocation into `.trash/`
|
||||
// and the key's removal are the store's, through the Writer.
|
||||
if card.isDeleted {
|
||||
legacyTombstones.append(LegacyTombstone(
|
||||
defects.append(.legacyTombstone(LegacyTombstone(
|
||||
kind: .card,
|
||||
laneID: ItemID(rawValue: laneName),
|
||||
cardID: ItemID(rawValue: cardName),
|
||||
title: card.title.value
|
||||
))
|
||||
)))
|
||||
logger.info("\(cardRelPath, privacy: .public): legacy 'deleted' key — card to be relocated into \(trashFolderName, privacy: .public)/")
|
||||
}
|
||||
|
||||
@@ -235,12 +248,12 @@ public enum BoardLoader: Sendable {
|
||||
}
|
||||
|
||||
if !laneDocument.deleted.isMissing {
|
||||
legacyTombstones.append(LegacyTombstone(
|
||||
defects.append(.legacyTombstone(LegacyTombstone(
|
||||
kind: .lane,
|
||||
laneID: ItemID(rawValue: laneName),
|
||||
cardID: nil,
|
||||
title: laneDocument.title.value
|
||||
))
|
||||
)))
|
||||
logger.info("\(laneName, privacy: .public): legacy 'deleted' key — lane to be returned live with the key removed")
|
||||
}
|
||||
|
||||
@@ -263,6 +276,7 @@ public enum BoardLoader: Sendable {
|
||||
}
|
||||
|
||||
var trash: [Card] = []
|
||||
var trashKinds: [ItemID: IntegrityRules.ObjectKind] = [:]
|
||||
for cardURL in trashCandidates(in: boardRoot) {
|
||||
let cardName = cardURL.lastPathComponent
|
||||
let cardRelPath = trashFolderName + "/" + cardName
|
||||
@@ -274,7 +288,17 @@ public enum BoardLoader: Sendable {
|
||||
warn(.missingIndex(path: cardRelPath))
|
||||
continue
|
||||
}
|
||||
trash.append(try parseCard(at: cardURL, path: cardRelPath))
|
||||
let entry = try parseCard(at: cardURL, path: cardRelPath)
|
||||
// **The trash's discriminator, applied where the flat container needs it**
|
||||
// (01-storage-format.md § Deletion, re-ruled 2026-07-29): the *value* is trusted
|
||||
// outright, and only an unrecognized value or no key at all falls through to shape.
|
||||
// Reading the shape half is one directory listing, and only when the value did not
|
||||
// answer — see `looksLikeALaneFolder(_:)`.
|
||||
trashKinds[entry.id] = IntegrityRules.trashKind(
|
||||
kindValue: entry.document.kind.value,
|
||||
hasIdentityShapedChildIndex: looksLikeALaneFolder(cardURL)
|
||||
)
|
||||
trash.append(entry)
|
||||
}
|
||||
|
||||
let model = BoardModel(
|
||||
@@ -297,11 +321,24 @@ public enum BoardLoader: Sendable {
|
||||
return LoadResult(
|
||||
model: model,
|
||||
warnings: warnings,
|
||||
looseCardFiles: looseCardFiles,
|
||||
legacyTombstones: legacyTombstones
|
||||
defects: defects,
|
||||
trashKinds: trashKinds
|
||||
)
|
||||
}
|
||||
|
||||
/// Whether a folder in `.trash/` has the *shape* of a lane — at least one identity-shaped child
|
||||
/// holding its own `index.md` (01-storage-format.md § Deletion: "UUID-shaped children with
|
||||
/// their own `index.md` → lane … else card").
|
||||
///
|
||||
/// Only reached when `kind` did not answer (`IntegrityRules.trashKind`'s `@autoclosure`), and
|
||||
/// deliberately not a parse: this asks what the folder *looks like*, not whether anything inside
|
||||
/// it would load. A trashed lane's cards are never enumerated as levels — the walk stops at a
|
||||
/// trash entry exactly as it stops at a card under a lane.
|
||||
private static func looksLikeALaneFolder(_ folder: URL) -> Bool {
|
||||
let children = (try? directoryCandidates(in: folder)) ?? []
|
||||
return children.contains { isUUIDShaped($0.lastPathComponent) && hasIndex($0) }
|
||||
}
|
||||
|
||||
/// One card folder read into a `Card` — **the card parse, shared by both containers**.
|
||||
///
|
||||
/// A trashed card is "an ordinary card in a special place" (03-board-ui.md § Trash), and this
|
||||
@@ -343,9 +380,11 @@ public enum BoardLoader: Sendable {
|
||||
/// **A `.trash` that is not a plain directory yields nothing**: a file by that name, or a
|
||||
/// *symlink* — "symlinks are never traversed" (01-storage-format.md § Fractal layout ▸ Rules),
|
||||
/// and a symlinked trash would render bytes living outside the board that FSEvents never
|
||||
/// reports. Logged rather than warned: `LoadWarning` is the stray vocabulary and a claimed
|
||||
/// name is not a stray, so there is no case here that fits and nothing for a user to do about
|
||||
/// a name the app claims.
|
||||
/// reports. Not a `LoadWarning`: that is the stray vocabulary, and a claimed name is not a
|
||||
/// stray. Since 2026-07-29 it is not merely logged either — the walk reports it as a
|
||||
/// `Defect.claimedNameSquatted` (detected up in `load`, before the lanes) and a scheduled heal
|
||||
/// displaces it. Until that heal lands the loader keeps this empty-trash read, which is exactly
|
||||
/// the "window measured in one reload, not a standing state" the ruling accepts.
|
||||
///
|
||||
/// Entries are `directoryCandidates` — hidden entries and symlinks already excluded, in
|
||||
/// folder-name order — so the trash gets the same stray tolerance every other container gets,
|
||||
@@ -360,7 +399,7 @@ public enum BoardLoader: Sendable {
|
||||
return []
|
||||
}
|
||||
guard values.isDirectory == true, values.isSymbolicLink != true else {
|
||||
logger.warning("\(trashFolderName, privacy: .public): not a plain directory, treated as an empty trash")
|
||||
logger.warning("\(trashFolderName, privacy: .public): not a plain directory, treated as an empty trash until the heal displaces it")
|
||||
return []
|
||||
}
|
||||
return (try? directoryCandidates(in: trashURL)) ?? []
|
||||
@@ -479,10 +518,6 @@ public enum BoardLoader: Sendable {
|
||||
.sorted { $0.localizedStandardCompare($1) == .orderedAscending }
|
||||
}
|
||||
|
||||
/// The hex characters `isUUIDShaped` accepts in each `-`-delimited group — **both cases**,
|
||||
/// per the shape-only identity predicate below.
|
||||
private static let uuidGroupCharacters = Set("0123456789abcdefABCDEF")
|
||||
|
||||
/// Whether `name` has a UUID's shape — hex, `8-4-4-4-12`, **any case and any version** —
|
||||
/// gating lane/card level detection (01-storage-format.md § Fractal layout ▸ Rules, "Name
|
||||
/// shape gates level detection"). This is *the* identity predicate, and it is deliberately
|
||||
@@ -509,10 +544,12 @@ public enum BoardLoader: Sendable {
|
||||
///
|
||||
/// Internal rather than `private`: `BoardWriter.renumberVisibleChildren` walks the same
|
||||
/// candidates the loader walked, and level detection has to be one rule, not two.
|
||||
///
|
||||
/// **The rule itself is `IntegrityRules.isIdentityShaped`** (settled 2026-07-29 — the one
|
||||
/// vocabulary of object validity). This is the loader's spelling of it and nothing more: one
|
||||
/// predicate, one implementation, no parallel derivation.
|
||||
static func isUUIDShaped(_ 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(uuidGroupCharacters.contains) }
|
||||
IntegrityRules.isIdentityShaped(name)
|
||||
}
|
||||
|
||||
/// Direct subdirectories of `folder`, in deterministic (folder-name) order, excluding
|
||||
@@ -615,155 +652,92 @@ public enum BoardLoader: Sendable {
|
||||
///
|
||||
/// - Parameter path: what the error names — `indexFileName` from every call site today, which is
|
||||
/// what the card window's alert is about.
|
||||
///
|
||||
/// **The rule is `IntegrityRules.validateIndex(_:path:kind:supportedSchema:)`**, generalized per
|
||||
/// kind (02-architecture.md ▸ Components). This spelling stays because it is what the card
|
||||
/// window asks — "would this load as a card?" — and because pinning the kind at the call site is
|
||||
/// what keeps the outlet's gate from drifting when a second kind gains one.
|
||||
public static func validateCardIndex(_ data: Data, path: String) throws(BoardLoadError) -> FrontmatterDocument {
|
||||
let document = try parseDocument(data, path: path)
|
||||
_ = try validatedSchema(in: document, path: path)
|
||||
_ = try validatedOrder(in: document, path: path)
|
||||
return document
|
||||
try IntegrityRules.validateIndex(data, path: path, kind: .card, supportedSchema: supportedSchema)
|
||||
}
|
||||
|
||||
/// The per-field validators are `IntegrityRules`' — the rulebook (02-architecture.md ▸
|
||||
/// Components). These two forward so the walk above reads as it always did.
|
||||
private static func validatedSchema(in document: FrontmatterDocument, path: String) 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
|
||||
}
|
||||
try IntegrityRules.validatedSchema(in: document, path: path, supportedSchema: supportedSchema)
|
||||
}
|
||||
|
||||
private 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
|
||||
}
|
||||
try IntegrityRules.validatedOrder(in: document, path: path)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Result
|
||||
|
||||
/// A successful load: the snapshot plus anything tolerated-but-notable encountered along the
|
||||
/// way. `warnings` is also logged as it accumulates (`os.Logger(subsystem: "dev.rzen.indie.Kanban",
|
||||
/// category: "loader")`) so it shows up in Console even if a caller never inspects it.
|
||||
/// A successful load: the snapshot, anything tolerated-but-notable encountered along the way, and
|
||||
/// the pending work the walk found. `warnings` is also logged as it accumulates
|
||||
/// (`os.Logger(subsystem: "dev.rzen.indie.Kanban", category: "loader")`) so it shows up in Console
|
||||
/// even if a caller never inspects it.
|
||||
public struct LoadResult: Sendable {
|
||||
public var model: BoardModel
|
||||
public var warnings: [LoadWarning]
|
||||
|
||||
/// The cards this walk found carrying loose files, in the order the walk met them — the
|
||||
/// loose-file carve-out's detection channel (01-storage-format.md § Fractal layout ▸ Rules,
|
||||
/// settled 2026-07-28).
|
||||
/// **The typed defect stream** — everything this walk found that is pending *work*
|
||||
/// (02-architecture.md ▸ Components ▸ IntegrityRules, settled 2026-07-29). One channel, not
|
||||
/// three: loose card files, legacy `deleted:` keys, and a claimed board-root name held by the
|
||||
/// wrong kind of node all classify as `IntegrityRules.Defect`, carry their own signature, and
|
||||
/// are healed by the one engine (`HealScheduler`).
|
||||
///
|
||||
/// **Its own field rather than a `LoadWarning` case**, because the two say opposite things.
|
||||
/// `warnings` is the *stray-tolerance* vocabulary: "this was ignored, it is staying exactly
|
||||
/// where it is, there is nothing to do". A loose card file is the one thing on a board that is
|
||||
/// **not** tolerated — it is pending work, and the store acts on it. Folding it into the
|
||||
/// warning channel would also mean throwing away everything the act needs (which lane, which
|
||||
/// card, which title, which names) and re-deriving it from a display string.
|
||||
/// **Deliberately not `warnings`**, which stays the *tolerate*-tier vocabulary: "this was
|
||||
/// ignored, it is staying exactly where it is, there is nothing to do". A defect says the
|
||||
/// opposite — it is work, and the store acts on it. Folding the two would also throw away
|
||||
/// everything a heal needs (which lane, which card, which title, which names) and force it to be
|
||||
/// re-derived from a display string.
|
||||
///
|
||||
/// Nothing renders this: a loose file is not content, and it reaches no view. Its one consumer
|
||||
/// is `BoardStore.relocateLooseCardFiles()`, which relocates and posts the notice.
|
||||
/// Nothing renders this. Order is the walk's: the board root's claimed names, then, lane by
|
||||
/// lane, each lane's cards and then the lane itself.
|
||||
///
|
||||
/// Tombstoned cards are included, and cards under tombstoned lanes with them. Where a file
|
||||
/// belongs on disk is a question about the *tree*, not about what the board is currently
|
||||
/// rendering — the same reason the loader flags a tombstoned card at all rather than dropping
|
||||
/// it.
|
||||
///
|
||||
/// **Cards in `.trash/` are deliberately *not* walked for loose files in this version.** The
|
||||
/// channel is keyed by lane (`LooseCardFiles.laneID`, the store's path key) and a trashed card
|
||||
/// has no lane; widening the key is the store-side change that belongs with the store-side
|
||||
/// scheduling. Loose files beside a trashed card's `index.md` therefore keep the ordinary
|
||||
/// stray posture — tolerated, preserved verbatim — and are tidied the moment the card is
|
||||
/// restored into a lane, which is the only state in which they matter.
|
||||
public var looseCardFiles: [LooseCardFiles] = []
|
||||
|
||||
/// The legacy `deleted:` keys this walk found — the retired tombstone model's **migration
|
||||
/// input** (01-storage-format.md § Deletion, resettled 2026-07-28: "Legacy `deleted:` keys
|
||||
/// migrate on load-and-write, never destroy").
|
||||
///
|
||||
/// **The `looseCardFiles` idiom, for the same reason it exists**: `warnings` is the
|
||||
/// stray-*tolerance* vocabulary — "this was ignored, it is staying exactly where it is, there
|
||||
/// is nothing to do" — and a legacy tombstone is the opposite, pending work the store acts on.
|
||||
/// Folding it into the warning channel would also throw away everything the act needs (which
|
||||
/// lane, which card, which title) and force it to be re-derived from a display string.
|
||||
///
|
||||
/// Nothing renders this. Its consumer is the store, which relocates each `.card` into
|
||||
/// `.trash/` with the key removed, strips each `.lane`'s key in place (a lane returns **live**
|
||||
/// — resurrection is the safe direction), and posts the warning-tone notice. Like the
|
||||
/// relocation it mirrors, the write is deferred under any read-only lock; the items stay
|
||||
/// rendered through the retiring tombstone path until it lands (see this type's `BoardLoader`
|
||||
/// note on the migration window).
|
||||
/// **Cards in `.trash/` are deliberately not walked for loose files in this version.** The
|
||||
/// loose-file defect is keyed by lane (`LooseCardFiles.laneID`, the store's path key) and a
|
||||
/// trashed card has no lane; widening the key is the store-side change that belongs with the
|
||||
/// store-side scheduling. Loose files beside a trashed card's `index.md` therefore keep the
|
||||
/// ordinary stray posture — tolerated, preserved verbatim — and are tidied the moment the card
|
||||
/// is restored into a lane, which is the only state in which they matter.
|
||||
///
|
||||
/// Board-level `deleted:` never appears here — it is meaningless, ignored and logged
|
||||
/// (`LoadWarning.boardLevelDeletedIgnored`), and nothing about it is the app's to rewrite.
|
||||
public var defects: [IntegrityRules.Defect] = []
|
||||
|
||||
/// What each `.trash/` entry **is**, by the trash's own discriminator
|
||||
/// (`IntegrityRules.trashKind`; 01-storage-format.md § Deletion, re-ruled 2026-07-29): the
|
||||
/// `kind` value trusted outright, falling through to shape only when it does not answer.
|
||||
///
|
||||
/// A *reading*, not a rendering: `BoardModel.trash` parses every entry through the one card
|
||||
/// parse (a trashed card is "an ordinary card in a special place"), and the container is flat,
|
||||
/// so this is where the answer to "which of these was a lane?" lives until the lanes-in-trash
|
||||
/// surface consumes it. Keyed by identity, so it survives the display sort.
|
||||
public var trashKinds: [ItemID: IntegrityRules.ObjectKind] = [:]
|
||||
|
||||
/// The cards this walk found holding loose files — a **view over `defects`**, under the name it
|
||||
/// has always had (01-storage-format.md § Fractal layout ▸ Rules, settled 2026-07-28).
|
||||
///
|
||||
/// In the order the walk met them, which is the order the relocation writes them in.
|
||||
public var looseCardFiles: [LooseCardFiles] {
|
||||
defects.compactMap { if case let .looseCardFiles(work) = $0 { work } else { nil } }
|
||||
}
|
||||
|
||||
/// The legacy `deleted:` keys this walk found — the retired tombstone model's migration input,
|
||||
/// as a **view over `defects`** (01-storage-format.md § Deletion).
|
||||
///
|
||||
/// Order is the walk's: a lane's tombstoned cards, then the lane itself, lane by lane.
|
||||
public var legacyTombstones: [LegacyTombstone] = []
|
||||
}
|
||||
|
||||
/// One item found carrying a legacy `deleted:` key — everything its migration and notice need,
|
||||
/// and nothing more.
|
||||
///
|
||||
/// The path is carried as its identity components rather than as a URL — `LooseCardFiles`'
|
||||
/// convention, for its reason: the write derives its path from the store's *current* root, which
|
||||
/// may have been re-resolved since the load. `title` is the item's as written, `nil` for an
|
||||
/// untitled one, because "Untitled" is a rendering and never a value (03-board-ui.md § Card face).
|
||||
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 var legacyTombstones: [LegacyTombstone] {
|
||||
defects.compactMap { if case let .legacyTombstone(work) = $0 { work } else { nil } }
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
/// One card found holding files that belong in its `attachments/` — everything the relocation and
|
||||
/// its notice need, and nothing more.
|
||||
///
|
||||
/// `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 entry 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
|
||||
/// The claimed board-root name found held by the wrong kind of node, if any — a **view over
|
||||
/// `defects`** (01-storage-format.md § Fractal layout ▸ Rules, ruled 2026-07-29).
|
||||
public var claimedNameSquatters: [ClaimedNameSquatter] {
|
||||
defects.compactMap { if case let .claimedNameSquatted(work) = $0 { work } else { nil } }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user