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:
@@ -49,8 +49,31 @@ public enum BoardWriter: Sendable {
|
||||
/// `modified-by` the user typed or kept — the validated-then-verbatim contract outranks the
|
||||
/// clearing rule (01-storage-format.md § Frontmatter). That path goes through
|
||||
/// `atomicReplace` directly; it does not belong here.
|
||||
///
|
||||
/// ## The on-touch heal seam
|
||||
///
|
||||
/// **This is where latent defects are fixed** (01-storage-format.md § Validation and healing;
|
||||
/// 02-architecture.md ▸ Components ▸ HealScheduler: "on-touch heals live at the Writer's
|
||||
/// `updateIndex` seam, which consults IntegrityRules for pending on-touch work on the file it is
|
||||
/// rewriting"). Between `edits` and the stamps, `IntegrityRules.healOnTouch` backfills a missing
|
||||
/// `kind` — the file is being rewritten anyway, so the fix costs nothing and rides the host
|
||||
/// write's single atomic rewrite, its `modified` stamp, and its commit. There is deliberately no
|
||||
/// scheduled sweep for it (re-ruled 2026-07-29): outside `.trash/` the key is redundant with
|
||||
/// position, and rewriting a whole board to add one would be churn for nothing.
|
||||
///
|
||||
/// The other two members of that class need no line here because they are already the editor's:
|
||||
/// `FrontmatterDocument.set` collapses duplicate-key twins on every key it writes, and
|
||||
/// `FrontmatterValue.emitScalar` quotes a value that needs it on first write. They are *named*
|
||||
/// in `IntegrityRules.OnTouchHeal` rather than re-implemented — same class, no behavior change.
|
||||
///
|
||||
/// - Parameter kind: the object's kind where the caller knows it and position cannot answer —
|
||||
/// **the board root**, whose folder name is a Finder document name rather than an identity.
|
||||
/// `nil`, the default, derives it from position (`IntegrityRules.placement`), and stamps
|
||||
/// nothing when position has no answer: a guessed kind on disk would be worse than an absent
|
||||
/// one, because the trash's discriminator trusts what it finds.
|
||||
public static func updateIndex(
|
||||
inItemFolder folder: URL,
|
||||
kind: IntegrityRules.ObjectKind? = nil,
|
||||
operation: WriteOperation,
|
||||
edits: (inout FrontmatterDocument) -> Void
|
||||
) throws(BoardWriteError) {
|
||||
@@ -64,12 +87,49 @@ public enum BoardWriter: Sendable {
|
||||
try checkEditable(document, at: indexURL, operation: operation)
|
||||
|
||||
edits(&document)
|
||||
// After `edits`, so a caller that wrote its own `kind` is left alone, and before the stamps,
|
||||
// which outrank everything for their own reason.
|
||||
IntegrityRules.healOnTouch(&document, kind: kind ?? derivedKind(ofItemFolder: folder))
|
||||
document.set(FrontmatterKeys.modified, to: .date(Date()))
|
||||
document.remove(FrontmatterKeys.modifiedBy)
|
||||
|
||||
try atomicReplace(text: document.serialized(), at: indexURL, operation: operation)
|
||||
}
|
||||
|
||||
/// The kind of the item in `folder`, read off its **position** — "level is position"
|
||||
/// (01-storage-format.md § Fractal layout), with the trash's flat container falling through to
|
||||
/// the discriminator that exists for exactly that (`IntegrityRules.trashKind`).
|
||||
///
|
||||
/// `nil` where position has no answer, which on a real board is only the board root (whose
|
||||
/// writers name their kind outright) and off it is any hand-named folder — a test fixture, a
|
||||
/// caller pointed at something that is not a level. Answering "board" there instead would stamp
|
||||
/// a kind onto whatever was pointed at, which is the one thing the value-names-the-kind posture
|
||||
/// cannot afford.
|
||||
private static func derivedKind(ofItemFolder folder: URL) -> IntegrityRules.ObjectKind? {
|
||||
switch IntegrityRules.placement(
|
||||
ofFolderNamed: folder.lastPathComponent,
|
||||
inParentNamed: folder.deletingLastPathComponent().lastPathComponent
|
||||
) {
|
||||
case .card:
|
||||
return .card
|
||||
case .lane:
|
||||
return .lane
|
||||
case .insideTrash:
|
||||
// The value cannot have answered — a document carrying `kind` is never backfilled, so
|
||||
// this is only reached for one that does not — which is precisely when shape decides.
|
||||
return IntegrityRules.trashKind(
|
||||
kindValue: nil,
|
||||
hasIdentityShapedChildIndex: childCandidates(of: folder).contains {
|
||||
FileManager.default.fileExists(
|
||||
atPath: $0.appendingPathComponent(BoardLoader.indexFileName).path
|
||||
)
|
||||
}
|
||||
)
|
||||
case .unknown:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Atomic replace
|
||||
|
||||
/// Writes `text` over `fileURL` atomically: a hidden temp file in the **same directory**,
|
||||
@@ -163,7 +223,11 @@ public enum BoardWriter: Sendable {
|
||||
)
|
||||
}
|
||||
|
||||
try atomicReplace(text: newDocumentText(title: title, order: nil), at: indexURL, operation: operation)
|
||||
try atomicReplace(
|
||||
text: newDocumentText(title: title, order: nil, kind: .board),
|
||||
at: indexURL,
|
||||
operation: operation
|
||||
)
|
||||
}
|
||||
|
||||
/// Creates a lane in a board: mints a fresh lowercase-UUIDv4 folder directly under
|
||||
@@ -171,7 +235,7 @@ public enum BoardWriter: Sendable {
|
||||
/// `index.md`. Returns the new identity. See `createChild(inParent:title:operation:)` for
|
||||
/// the shared mechanics.
|
||||
public static func createLane(inBoard rootURL: URL, title: String?) throws(BoardWriteError) -> ItemID {
|
||||
try createChild(inParent: rootURL, title: title, operation: .createLane)
|
||||
try createChild(inParent: rootURL, title: title, kind: .lane, operation: .createLane)
|
||||
}
|
||||
|
||||
/// Creates a card in a lane: mints a fresh lowercase-UUIDv4 folder directly under
|
||||
@@ -179,7 +243,7 @@ public enum BoardWriter: Sendable {
|
||||
/// Returns the new identity. See `createChild(inParent:title:operation:)` for the shared
|
||||
/// mechanics.
|
||||
public static func createCard(inLane laneURL: URL, title: String?) throws(BoardWriteError) -> ItemID {
|
||||
try createChild(inParent: laneURL, title: title, operation: .createCard)
|
||||
try createChild(inParent: laneURL, title: title, kind: .card, operation: .createCard)
|
||||
}
|
||||
|
||||
/// The shared body of `createLane`/`createCard` — a lane under a board and a card under a
|
||||
@@ -203,6 +267,7 @@ public enum BoardWriter: Sendable {
|
||||
private static func createChild(
|
||||
inParent parentFolder: URL,
|
||||
title: String?,
|
||||
kind: IntegrityRules.ObjectKind,
|
||||
operation: WriteOperation
|
||||
) throws(BoardWriteError) -> ItemID {
|
||||
try checkIsDirectory(parentFolder, describedAs: "parent folder", operation: operation)
|
||||
@@ -212,7 +277,11 @@ public enum BoardWriter: Sendable {
|
||||
|
||||
let folder = try mintUUIDFolder(in: parentFolder, operation: operation)
|
||||
let indexURL = folder.appendingPathComponent(BoardLoader.indexFileName)
|
||||
try atomicReplace(text: newDocumentText(title: title, order: order), at: indexURL, operation: operation)
|
||||
try atomicReplace(
|
||||
text: newDocumentText(title: title, order: order, kind: kind),
|
||||
at: indexURL,
|
||||
operation: operation
|
||||
)
|
||||
|
||||
return ItemID(rawValue: folder.lastPathComponent)
|
||||
}
|
||||
@@ -224,9 +293,21 @@ public enum BoardWriter: Sendable {
|
||||
/// `modified-by` is never written, matching the engine's absence-means-app-authored
|
||||
/// convention (§ Frontmatter). Key order — `schema`, `title` (only when supplied), `order`
|
||||
/// (only when supplied — `nil` for a board, always present for a lane/card), `created`,
|
||||
/// `modified` — is simply the order `set` is called in, since each call appends a fresh key
|
||||
/// before the closing delimiter of an otherwise-empty document.
|
||||
private static func newDocumentText(title: String?, order: Double?) -> String {
|
||||
/// `modified`, `kind` — is simply the order `set` is called in, since each call appends a fresh
|
||||
/// key before the closing delimiter of an otherwise-empty document. `kind` goes last because
|
||||
/// that is where the common table puts it, and because it is where the on-touch backfill appends
|
||||
/// one on an older file: a created object and a healed one end up spelled the same way.
|
||||
///
|
||||
/// **`kind` is written at creation of every object** (01-storage-format.md § Frontmatter,
|
||||
/// re-ruled 2026-07-29 — "consistency across the schema, even where position already answers").
|
||||
/// Bundled and user templates carry files without it; they gain it lazily through the on-touch
|
||||
/// backfill on the first write that rewrites them, which is exactly what that heal is for — no
|
||||
/// template migration.
|
||||
private static func newDocumentText(
|
||||
title: String?,
|
||||
order: Double?,
|
||||
kind: IntegrityRules.ObjectKind
|
||||
) -> String {
|
||||
var document = FrontmatterDocument(body: "")
|
||||
document.set(FrontmatterKeys.schema, to: .int(BoardLoader.supportedSchema))
|
||||
if let title {
|
||||
@@ -238,6 +319,7 @@ public enum BoardWriter: Sendable {
|
||||
let now = Date()
|
||||
document.set(FrontmatterKeys.created, to: .date(now))
|
||||
document.set(FrontmatterKeys.modified, to: .date(now))
|
||||
document.set(FrontmatterKeys.kind, to: .string(kind.rawValue))
|
||||
return document.serialized()
|
||||
}
|
||||
|
||||
@@ -273,7 +355,7 @@ public enum BoardWriter: Sendable {
|
||||
/// `parentFolder` (so the caller's `createDirectory`/`moveItem`/`copyItem` cannot lose a
|
||||
/// race with an existing entry), and any name in `taken` — the identities a collision repair
|
||||
/// is minting *away* from, which are not necessarily on disk here. **`taken` is canonical**
|
||||
/// (lowercased, `canonicalIdentity`), which is what makes the `contains` a UUID-*value*
|
||||
/// (lowercased, `IntegrityRules.canonicalIdentity` — the one canonicalization, shared with `ItemID`), which is what makes the `contains` a UUID-*value*
|
||||
/// probe: the minted name is lowercase, so it can only match a canonical set. A freshly
|
||||
/// minted UUID hitting either is astronomically unlikely — 122 bits of randomness per mint —
|
||||
/// but the loop body is trivial precisely because the case it handles essentially never fires.
|
||||
@@ -450,7 +532,7 @@ public enum BoardWriter: Sendable {
|
||||
/// `isUUIDShaped`; strays skipped, tombstones kept) — but only on an import, since a
|
||||
/// same-board move cannot collide with anything but itself. The scan and every probe
|
||||
/// against it are **by UUID value, not spelling** (`identities(inBoard:)` /
|
||||
/// `canonicalIdentity`): an arriving `55555555-…` collides with a resident `55555555-…`
|
||||
/// `IntegrityRules.canonicalIdentity`): an arriving `55555555-…` collides with a resident `55555555-…`
|
||||
/// spelled in uppercase, because those are one identity (§ Fractal layout ▸ Rules).
|
||||
/// 5. **Move the folder** (`FileManager.moveItem`, which degrades to copy+remove across
|
||||
/// volumes). A colliding *root* is renamed by moving it straight to its minted name
|
||||
@@ -507,10 +589,10 @@ public enum BoardWriter: Sendable {
|
||||
// "Which sibling is the item itself" is an identity question, so it is asked by
|
||||
// UUID value (`canonicalIdentity`), not by spelling: the caller's URL and the
|
||||
// directory listing can disagree in case for one and the same folder.
|
||||
let selfIdentity = canonicalIdentity(sourceName)
|
||||
let selfIdentity = IntegrityRules.canonicalIdentity(sourceName)
|
||||
rank = Ranks.append(
|
||||
toVisible: siblings
|
||||
.filter { canonicalIdentity($0.folder.lastPathComponent) != selfIdentity }
|
||||
.filter { IntegrityRules.canonicalIdentity($0.folder.lastPathComponent) != selfIdentity }
|
||||
.map(\.order)
|
||||
)
|
||||
}
|
||||
@@ -528,7 +610,7 @@ public enum BoardWriter: Sendable {
|
||||
var reminted: [MoveResult.Remint] = []
|
||||
|
||||
var arrivedName = sourceName
|
||||
if existing.contains(canonicalIdentity(sourceName)) {
|
||||
if existing.contains(IntegrityRules.canonicalIdentity(sourceName)) {
|
||||
arrivedName = freshUUIDName(in: destinationParent, avoiding: reserved)
|
||||
reserved.insert(arrivedName)
|
||||
reminted.append(MoveResult.Remint(from: ItemID(rawValue: sourceName), to: ItemID(rawValue: arrivedName)))
|
||||
@@ -550,8 +632,8 @@ public enum BoardWriter: Sendable {
|
||||
|
||||
if isImport {
|
||||
let children = childCandidates(of: arrivedRoot)
|
||||
reserved.formUnion(children.map { canonicalIdentity($0.lastPathComponent) })
|
||||
for child in children where existing.contains(canonicalIdentity(child.lastPathComponent)) {
|
||||
reserved.formUnion(children.map { IntegrityRules.canonicalIdentity($0.lastPathComponent) })
|
||||
for child in children where existing.contains(IntegrityRules.canonicalIdentity(child.lastPathComponent)) {
|
||||
let fresh = freshUUIDName(in: arrivedRoot, avoiding: reserved)
|
||||
reserved.insert(fresh)
|
||||
try renameFolder(child, toSiblingNamed: fresh, operation: operation)
|
||||
@@ -584,7 +666,7 @@ public enum BoardWriter: Sendable {
|
||||
/// and the conservative direction here — a missed identity remints nothing, and a duplicate
|
||||
/// UUID in one board is the unspecified-behavior case the design already names, not a
|
||||
/// corruption.
|
||||
/// **Canonical, not verbatim**: every name is lowercased on the way in (`canonicalIdentity`),
|
||||
/// **Canonical, not verbatim**: every name is lowercased on the way in (`IntegrityRules.canonicalIdentity`),
|
||||
/// and every probe against the returned set must be too. Identity comparison is UUID-*value*
|
||||
/// equality, never string equality (§ Fractal layout ▸ Rules, settled) — an arriving
|
||||
/// `55555555-…` and a resident `55555555-…` spelled uppercase are **one** identity, and a
|
||||
@@ -592,9 +674,9 @@ public enum BoardWriter: Sendable {
|
||||
private static func identities(inBoard boardRoot: URL) -> Set<String> {
|
||||
var identities: Set<String> = []
|
||||
for lane in childCandidates(of: boardRoot) {
|
||||
identities.insert(canonicalIdentity(lane.lastPathComponent))
|
||||
identities.insert(IntegrityRules.canonicalIdentity(lane.lastPathComponent))
|
||||
for card in childCandidates(of: lane) {
|
||||
identities.insert(canonicalIdentity(card.lastPathComponent))
|
||||
identities.insert(IntegrityRules.canonicalIdentity(card.lastPathComponent))
|
||||
}
|
||||
}
|
||||
// **The trash counts.** Board-wide uniqueness spans both containers (01-storage-format.md
|
||||
@@ -605,19 +687,11 @@ public enum BoardWriter: Sendable {
|
||||
// This is also what makes `deleteCardToTrash`'s "collision is impossible" true rather than
|
||||
// hopeful: an import that would have produced the twin was reminted before it landed.
|
||||
for card in childCandidates(of: trashFolder(inBoard: boardRoot)) {
|
||||
identities.insert(canonicalIdentity(card.lastPathComponent))
|
||||
identities.insert(IntegrityRules.canonicalIdentity(card.lastPathComponent))
|
||||
}
|
||||
return identities
|
||||
}
|
||||
|
||||
/// A folder name reduced to its identity *value* — the same canonicalization `ItemID`'s
|
||||
/// `==`/`hash(into:)` use (`BoardModel.swift`), applied where this writer must compare names
|
||||
/// as strings because it is working with paths rather than model values. Every identity-shaped
|
||||
/// name is ASCII hex and hyphens, so case folding is UUID-value canonicalization exactly.
|
||||
private static func canonicalIdentity(_ folderName: String) -> String {
|
||||
folderName.lowercased()
|
||||
}
|
||||
|
||||
/// A folder's UUID-shaped subfolders in deterministic order — `directoryCandidates` (hidden
|
||||
/// entries and symlinks already excluded) narrowed by `isUUIDShaped`, which is the loader's
|
||||
/// level-detection rule and therefore the only definition of "an identity-bearing child"
|
||||
@@ -1016,6 +1090,13 @@ public enum BoardWriter: Sendable {
|
||||
document.remove(FrontmatterKeys.deleted)
|
||||
}
|
||||
}
|
||||
if removingLegacyKey {
|
||||
// Heal-marked: the migration is work the app started on its own, and its paths commit
|
||||
// separately (06-history-undo.md ▸ Commit messages, ruled 2026-07-29). The ordinary
|
||||
// delete this shares a body with is a *gesture* and is deliberately not marked.
|
||||
EchoLedger.current?.markHeal(at: arrived)
|
||||
EchoLedger.current?.markHeal(at: arrived.appendingPathComponent(BoardLoader.indexFileName))
|
||||
}
|
||||
|
||||
return ItemID(rawValue: name)
|
||||
}
|
||||
@@ -1044,6 +1125,7 @@ public enum BoardWriter: Sendable {
|
||||
try updateIndex(inItemFolder: laneFolder, operation: operation) { document in
|
||||
document.remove(FrontmatterKeys.deleted)
|
||||
}
|
||||
EchoLedger.current?.markHeal(at: laneFolder.appendingPathComponent(BoardLoader.indexFileName))
|
||||
}
|
||||
|
||||
/// **Deleting a lane is physical** — the folder and everything under it are removed
|
||||
@@ -1738,8 +1820,9 @@ public enum BoardWriter: Sendable {
|
||||
/// The one folder this app ever creates under a card — every other subfolder under
|
||||
/// `attachments/` is tolerated but never made or named by the app (01-storage-format.md §
|
||||
/// Attachments). Internal rather than `private`: `importAttachments` and `listAttachments`
|
||||
/// must never disagree about which folder holds a card's files.
|
||||
static let attachmentsFolderName = "attachments"
|
||||
/// must never disagree about which folder holds a card's files. The name itself is
|
||||
/// `IntegrityRules`', with the rest of the reserved-name tables.
|
||||
static let attachmentsFolderName = IntegrityRules.attachmentsFolderName
|
||||
|
||||
/// Imports files into a card's `attachments/` folder, creating it on first import — the
|
||||
/// write side of 01-storage-format.md § Attachments. **Never refuses the drop**: a name
|
||||
@@ -1872,10 +1955,17 @@ public enum BoardWriter: Sendable {
|
||||
/// **`cardFolder` must really be a card** (`checkIsCardFolder`, which is stricter than the
|
||||
/// UUID-shape guard the rest of this file uses): a lane's own loose files keep the verbatim
|
||||
/// posture, and no other write in the app has to tell the two levels apart.
|
||||
///
|
||||
/// - Parameter healMarked: whether the receipts this drops are marked as a heal's
|
||||
/// (06-history-undo.md ▸ Commit messages, ruled 2026-07-29). `true` for the scheduled
|
||||
/// relocation, which is app-initiated work that commits separately; `false` for the
|
||||
/// import-boundary normalization below, which is **inline** — it batches with the gesture that
|
||||
/// triggered it and belongs in that gesture's commit, not in a heal's.
|
||||
@discardableResult
|
||||
public static func relocateLooseFiles(
|
||||
_ names: [String],
|
||||
inCard cardFolder: URL
|
||||
inCard cardFolder: URL,
|
||||
healMarked: Bool = true
|
||||
) throws(BoardWriteError) -> [ImportedAttachment] {
|
||||
guard !names.isEmpty else { return [] }
|
||||
|
||||
@@ -1912,7 +2002,10 @@ public enum BoardWriter: Sendable {
|
||||
try FileManager.default.moveItem(at: sourceURL, to: landedURL)
|
||||
// A move pair, not a write: the bytes were not touched, only their place — and the
|
||||
// pair is what tells the classifier the loose file's disappearance was the app's.
|
||||
// Heal-marked: the relocation is app-initiated work whose paths commit separately
|
||||
// (06-history-undo.md ▸ Commit messages, ruled 2026-07-29).
|
||||
EchoLedger.current?.recordMove(from: sourceURL, to: landedURL)
|
||||
if healMarked { EchoLedger.current?.markHeal(at: landedURL) }
|
||||
} catch {
|
||||
throw BoardWriteError(
|
||||
operation: operation,
|
||||
@@ -1939,7 +2032,14 @@ public enum BoardWriter: Sendable {
|
||||
/// A card with nothing loose is one directory listing and no write at all.
|
||||
@discardableResult
|
||||
public static func normalizeLooseFiles(inCard cardFolder: URL) throws(BoardWriteError) -> [ImportedAttachment] {
|
||||
try relocateLooseFiles(BoardLoader.looseFileNames(in: cardFolder), inCard: cardFolder)
|
||||
// `healMarked: false` — an **inline** heal batches with the gesture that triggered it
|
||||
// (01-storage-format.md § Validation and healing), so its paths are the paste's, not a
|
||||
// heal's, and splitting them out would name a commit for work the user asked for.
|
||||
try relocateLooseFiles(
|
||||
BoardLoader.looseFileNames(in: cardFolder),
|
||||
inCard: cardFolder,
|
||||
healMarked: false
|
||||
)
|
||||
}
|
||||
|
||||
/// The lane-level face of the same import-boundary normalization: every card of an arriving
|
||||
@@ -2016,8 +2116,24 @@ public enum BoardWriter: Sendable {
|
||||
/// `fileExists` is the one test, and it is true for a directory as much as a file — a
|
||||
/// same-named *subfolder* blocks the name exactly like a file would, so an import never
|
||||
/// overwrites, renames, or descends into one; it just renames the incoming file instead.
|
||||
///
|
||||
/// **The ladder itself is `freshName(for:in:)`**, shared with the claimed-name displacement
|
||||
/// (`.trash` → `.trash 2`, ruled 2026-07-29): "Finder-style rename on collision" is one rule
|
||||
/// wherever the app has to find a free name, and two copies of it would be two ladders to keep
|
||||
/// climbing the same way.
|
||||
private static func freshAttachmentName(for originalName: String, in folder: URL) -> String {
|
||||
guard FileManager.default.fileExists(atPath: folder.appendingPathComponent(originalName).path) else {
|
||||
freshName(for: originalName, in: folder)
|
||||
}
|
||||
|
||||
/// The Finder-style collision-free name for `originalName` inside `folder` — see
|
||||
/// `freshAttachmentName(for:in:)` for the splitting rules, which are Finder's own.
|
||||
///
|
||||
/// **`lstat` semantics, not `stat`**: a *dangling symlink* holds its name as firmly as any other
|
||||
/// node, and `fileExists` — which follows links — would call that name free and hand the caller
|
||||
/// a move that fails. `IntegrityRules.node(at:)` is the one probe, which is also what makes this
|
||||
/// safe for the displacement, whose whole subject may itself be a symlink.
|
||||
static func freshName(for originalName: String, in folder: URL) -> String {
|
||||
guard IntegrityRules.node(at: folder.appendingPathComponent(originalName)) != nil else {
|
||||
return originalName
|
||||
}
|
||||
|
||||
@@ -2028,13 +2144,60 @@ public enum BoardWriter: Sendable {
|
||||
var counter = 2
|
||||
while true {
|
||||
let candidate = ext.isEmpty ? "\(base) \(counter)" : "\(base) \(counter).\(ext)"
|
||||
guard FileManager.default.fileExists(atPath: folder.appendingPathComponent(candidate).path) else {
|
||||
guard IntegrityRules.node(at: folder.appendingPathComponent(candidate)) != nil else {
|
||||
return candidate
|
||||
}
|
||||
counter += 1
|
||||
}
|
||||
}
|
||||
|
||||
/// **Displaces a squatter off a claimed name** — the write half of the claimed-names ruling
|
||||
/// (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").
|
||||
///
|
||||
/// A rename and nothing else: the node moves to its Finder-ladder name in the same folder
|
||||
/// (`.trash` → `.trash 2`), **preserved verbatim, never destroyed** — the invariant that survives
|
||||
/// the ruling is displacement-never-destruction. Its contents are never opened, and a symlink is
|
||||
/// moved *as a link*, never followed (`FileManager.moveItem` renames the link itself).
|
||||
///
|
||||
/// **It re-verifies against disk** (§ Validation and healing: "every scheduled heal re-verifies
|
||||
/// its defect against disk at write time and no-ops when it is gone"): `nil` when the name is
|
||||
/// free again, or when the right kind of node is now sitting there — losing the race to a
|
||||
/// foreign fix is success, never an error.
|
||||
///
|
||||
/// **It stamps nothing.** A heal that only renames never opens an `index.md`, so the existing
|
||||
/// write discipline decides and there is no rule to add (§ Validation and healing).
|
||||
///
|
||||
/// - Returns: the name the squatter now has, or `nil` when the defect was already gone.
|
||||
@discardableResult
|
||||
public static func displaceClaimedName(
|
||||
_ squatter: ClaimedNameSquatter,
|
||||
atBoardRoot root: URL
|
||||
) throws(BoardWriteError) -> String? {
|
||||
let operation = WriteOperation.displaceClaimedName(name: squatter.name)
|
||||
let occupied = root.appendingPathComponent(squatter.name)
|
||||
guard let found = IntegrityRules.node(at: occupied), found != squatter.expected else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let freed = freshName(for: squatter.name, in: root)
|
||||
let destination = root.appendingPathComponent(freed)
|
||||
do {
|
||||
try FileManager.default.moveItem(at: occupied, to: destination)
|
||||
} catch {
|
||||
throw BoardWriteError(
|
||||
operation: operation,
|
||||
path: occupied.path,
|
||||
reason: .io(message: "could not move it aside to '\(freed)': \(error.localizedDescription)")
|
||||
)
|
||||
}
|
||||
// A folder move like any other as far as provenance goes — and heal-marked, because the app
|
||||
// started this on its own (06-history-undo.md ▸ Commit messages, the heal class).
|
||||
EchoLedger.current?.recordMove(from: occupied, to: destination)
|
||||
EchoLedger.current?.markHeal(at: destination)
|
||||
return freed
|
||||
}
|
||||
|
||||
/// The card's flat attachment listing (01-storage-format.md § Attachments, "the app's
|
||||
/// attachment surfaces … are flat: top-level files only"): the top-level *files* of
|
||||
/// `attachments/`, subfolders and hidden files excluded, symlinks excluded, sorted
|
||||
@@ -2412,6 +2575,19 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible {
|
||||
/// second phrasing for "couldn't move a file you have never seen" would explain nothing.
|
||||
case agentGuide
|
||||
|
||||
/// A wrong-kinded node being moved off a board-root name the app claims — a file or symlink
|
||||
/// squatting `.trash` (01-storage-format.md § Fractal layout ▸ Rules, ruled 2026-07-29: "moved
|
||||
/// aside by a scheduled heal via the Finder-style rename ladder").
|
||||
///
|
||||
/// Its own case on `.relocateLooseFile`'s and `.agentGuide`'s reasoning: this is work the *app*
|
||||
/// started on its own, on a node the user may not know is a problem, and a banner saying the app
|
||||
/// "couldn't delete" or "couldn't move" something would name a gesture that never happened.
|
||||
/// `name` is the claimed name as the app spells it — the name the user would recognize.
|
||||
///
|
||||
/// The `CLAUDE.md` squatter takes `.agentGuide` instead, because the guide's own write bracket
|
||||
/// owns that file end to end and has one outcome the user could care about.
|
||||
case displaceClaimedName(name: String)
|
||||
|
||||
/// A Preview task-list checkbox being ticked or unticked (05-card-window.md ▸ Preview).
|
||||
///
|
||||
/// Its own case rather than a fold into `.rename`'s or `.style`'s neighbourhood, on the
|
||||
@@ -2459,7 +2635,8 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible {
|
||||
public func withTitle(_ title: String?) -> WriteOperation {
|
||||
switch self {
|
||||
case .createBoard, .createLane, .createCard, .importAttachment, .listAttachments,
|
||||
.removeAttachment, .renumberChildren, .relocateLooseFile, .agentGuide:
|
||||
.removeAttachment, .renumberChildren, .relocateLooseFile, .agentGuide,
|
||||
.displaceClaimedName:
|
||||
self
|
||||
case .move: .move(title: title)
|
||||
case .reorder: .reorder(title: title)
|
||||
@@ -2506,6 +2683,7 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible {
|
||||
case .renumberChildren: "renumber children"
|
||||
case let .relocateLooseFile(filename): "relocate loose file '\(filename)'"
|
||||
case .agentGuide: "update the agent guide"
|
||||
case let .displaceClaimedName(name): "move a stray '\(name)' aside"
|
||||
case let .toggleTask(title): Self.phrase("toggle a checkbox in", title)
|
||||
case let .editBody(title): Self.phrase("save the body of", title)
|
||||
case let .rawSource(title): Self.phrase("apply source changes to", title)
|
||||
|
||||
Reference in New Issue
Block a user