import Foundation /// Turns a mutation into a filesystem operation — the single point through which every write /// the app makes reaches disk (02-architecture.md § Layering ▸ Components). Stateless by /// construction: there is no in-flight buffer, no queue, no coalescing. **A write is done when /// the rename completes**, and a failed write is a failure the caller sees, so views — which /// render only what is on disk — can never show phantom state (02-architecture.md § /// Write-failure surfacing). /// /// Four rules from 01-storage-format.md live here and are not negotiable per call site: /// /// - **Atomic writes** (§ Fractal layout ▸ Rules): temp file, rename over `index.md`. Every /// write, no exceptions — a reader (this app's watcher, an agent, git) never sees a partial /// file, and a crash mid-write leaves the previous content intact. /// - **Round-trip, never re-serialize**: mutations go through `FrontmatterDocument`, which /// edits by line span, so unknown keys and their order, comments, blank lines, line endings, /// and the body survive every write by construction rather than by remembering to preserve /// them. /// - **`modified` stamped, `modified-by` cleared** (§ Frontmatter): on every app-mediated write /// path. Absence of `modified-by` means "the board's user, via the app"; the file is being /// rewritten anyway, so clearing an external writer's self-reported stamp costs nothing. /// - **Encoding** (§ Fractal layout ▸ Rules): writes are BOM-less UTF-8; reads are strict /// UTF-8, and a file that does not decode is a loud, specific error rather than a /// lossy best guess. public enum BoardWriter: Sendable { // MARK: - The uniform per-file mutation /// Rewrites one item's `index.md`: read fresh, refuse what cannot be edited, apply `edits`, /// stamp, write atomically. /// /// The order of the four steps is the contract: /// /// 1. **Read fresh from disk**, never from a snapshot. The snapshot a caller is holding may /// be seconds stale — an agent or a hand-editor may have rewritten the file since — and /// the round-trip guarantee is only worth anything against the bytes actually there. /// 2. **Refuse an uneditable shape before `edits` runs** (`FrontmatterDocument.uneditableShape`): /// the settled readable-but-uneditable rule. Such a file loads and renders fine, but a /// surgical edit of it cannot be expressed, so the write fails loudly instead of /// corrupting it. Refusing up front also means `edits` never observes a document it /// cannot affect. /// 3. **`edits`, then the stamps** — `modified` set and `modified-by` removed *after* the /// caller's closure, so the stamp always wins over anything the closure did with those /// two keys, and no call site has to remember them. /// 4. **Atomic replace.** /// /// The **one path that deliberately bypasses this** is the card window's raw-source Apply /// (05-card-window.md): it writes the user's text byte-for-byte and does *not* clear a /// `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) { let indexURL = folder.appendingPathComponent(BoardLoader.indexFileName) var document = try readDocument(at: indexURL, operation: operation) // The read just above is *the* title-enrichment point for every case that funnels // through here (renumber, delete, restore, style, and the tail of move/copy): shadow // the parameter so every failure from here on — the uneditable-shape refusal, the // atomic replace — names the item. let operation = operation.withTitle(document.title.value) 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**, /// then a rename over the destination. /// /// Same directory because a rename is only atomic within one filesystem — a temp in /// `NSTemporaryDirectory()` could land on another volume and degrade to a copy. Dot-prefixed /// (`.index.md.lanework-`) because `BoardLoader.directoryCandidates` skips hidden /// entries: residue from a crashed write is invisible to a load rather than a stray warning /// or, worse, a candidate. The UUID keeps concurrent writers off each other's temp file. /// /// `Data(text.utf8)` is BOM-less UTF-8 by construction — the encoding contract, with no /// encoder to configure and no failure case to handle. On any failure the temp file is /// removed best-effort and `.io` is thrown: the destination is either the old bytes or the /// new ones, never a mix, and never a directory littered with half-written files. static func atomicReplace(text: String, at fileURL: URL, operation: WriteOperation) throws(BoardWriteError) { let directory = fileURL.deletingLastPathComponent() let tempURL = directory.appendingPathComponent(".\(fileURL.lastPathComponent).lanework-\(UUID().uuidString)") do { try Data(text.utf8).write(to: tempURL) } catch { try? FileManager.default.removeItem(at: tempURL) throw BoardWriteError( operation: operation, path: fileURL.path, reason: .io(message: "could not write temporary file: \(error.localizedDescription)") ) } // POSIX `rename` rather than `FileManager.replaceItemAt`: it atomically overwrites an // existing destination *and* handles one that does not exist yet (the create paths), // without inventing a second temp file of its own. let status = tempURL.withUnsafeFileSystemRepresentation { source in fileURL.withUnsafeFileSystemRepresentation { destination in guard let source, let destination else { return EINVAL } return rename(source, destination) == 0 ? 0 : errno } } guard status == 0 else { try? FileManager.default.removeItem(at: tempURL) throw BoardWriteError( operation: operation, path: fileURL.path, reason: .io(message: "could not replace file: \(String(cString: strerror(status)))") ) } // **The receipt, dropped after the bytes land and before the call returns** (the // EchoLedger's contract, 02-architecture.md ▸ Components). This one line covers every // `index.md` in the app: `updateIndex` funnels here, and so do create, materialize, // recreate, the task-marker flip, the body save and the raw-source Apply. EchoLedger.current?.recordWrite(at: fileURL, text: text) } // MARK: - Create /// Creates a new board: the folder (if it does not already exist) and its `index.md` — the /// write side of `.kanban` packaging (01-storage-format.md § Document packaging). Two /// callers: template instantiation, which passes the user-chosen document name as `title` /// so the window title and the Finder name start out matching (§ Board naming), and a bare /// "New Board" flow, which passes `nil` and lets the UI fall back to the folder name. /// /// - `rootURL` is created if missing (`withIntermediateDirectories: true`); an existing /// *empty* folder is simply filled in, since that is exactly what an interrupted create or /// a hand-made folder looks like. /// - **Refuses to clobber an existing board**: if `index.md` is already there, the call /// fails `.io` naming the path and the existing file is never touched — a create must /// never overwrite a board that already exists. /// - `title == nil` writes no `title` key at all — the folder-name fallback (§ Board /// naming) is a *missing* key, not an empty string, which would be a real (if blank) title. /// - Key order: `schema`, `title` (only when supplied), `created`, `modified`. No `order` — /// that field belongs to lanes and cards, never the board root. /// - Extension-less board folders are exactly as legal a target as a `.kanban`-suffixed one /// (§ Document packaging, "Extension-less board folders still open") — this call never /// looks at `rootURL`'s extension. public static func createBoard(at rootURL: URL, title: String?) throws(BoardWriteError) { let operation = WriteOperation.createBoard let indexURL = rootURL.appendingPathComponent(BoardLoader.indexFileName) guard !FileManager.default.fileExists(atPath: indexURL.path) else { throw BoardWriteError(operation: operation, path: indexURL.path, reason: .io(message: "a board already exists here")) } do { try FileManager.default.createDirectory(at: rootURL, withIntermediateDirectories: true) } catch { throw BoardWriteError( operation: operation, path: rootURL.path, reason: .io(message: "could not create board folder: \(error.localizedDescription)") ) } 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 /// `rootURL`, appends it after the board's current visible lanes, and writes its /// `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, kind: .lane, operation: .createLane) } /// Creates a card in a lane: mints a fresh lowercase-UUIDv4 folder directly under /// `laneURL`, appends it after the lane's current visible cards, and writes its `index.md`. /// 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, kind: .card, operation: .createCard) } /// The shared body of `createLane`/`createCard` — a lane under a board and a card under a /// lane are the same operation one level apart (01-storage-format.md § Fractal layout, /// "Level is position"): check the parent, place the new identity after the current visible /// siblings, mint it, write its file. /// /// - **The parent must already exist as a directory.** Anything else — missing, or a file /// where a folder belongs — is a loud `.unreadable` naming `parentFolder` up front, never /// a silent `mkdir` into a broken location. /// - **Order is `max + 1024` among visible siblings** (`Ranks.append(toVisible:)`), read by /// `visibleSiblings(of:operation:)` — the identical strict scan `renumberVisibleChildren` /// uses, so the two can never disagree about who counts as a sibling. An empty parent's /// first child lands at `1024`, the board convention (01-storage-format.md § Ordering). /// - **Two-step creation is inherent, not a shortcut taken here**: the folder is created, /// then `index.md` is written, as two separate filesystem operations — there is no atomic /// "create a directory with content already in it" primitive to reach for. A crash between /// the two leaves a UUID-shaped folder with no `index.md`, exactly the shape /// `BoardLoader`'s `.missingIndex` skip-and-warn rule (and `visibleSiblings`'s own /// `fileExists` check) already tolerate — see that type's doc comment. 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) let siblings = try visibleSiblings(of: parentFolder, operation: operation, requireEditable: false) let order = Ranks.append(toVisible: siblings.map(\.order)) let folder = try mintUUIDFolder(in: parentFolder, operation: operation) let indexURL = folder.appendingPathComponent(BoardLoader.indexFileName) try atomicReplace( text: newDocumentText(title: title, order: order, kind: kind), at: indexURL, operation: operation ) return ItemID(rawValue: folder.lastPathComponent) } /// The frontmatter text for a file the app is minting outright — never a rewrite, so this /// goes through `FrontmatterDocument(body:)` and `set`, not `updateIndex`: there is no prior /// document to read fresh, refuse an uneditable shape from, or stamp over. `created` and /// `modified` share one `Date()` so the two stamps are identical, not merely close; /// `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`, `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 { document.set(FrontmatterKeys.title, to: .string(title)) } if let order { document.set(FrontmatterKeys.order, to: .double(order)) } 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() } /// A fresh lowercase-UUIDv4 folder under `parentFolder` — the create path's mint, which /// materializes the folder as well as naming it. The naming rule itself lives in /// `freshUUIDName(in:avoiding:)`, shared with the move and copy paths so every identity the /// app mints is minted one way. private static func mintUUIDFolder(in parentFolder: URL, operation: WriteOperation) throws(BoardWriteError) -> URL { let candidate = parentFolder.appendingPathComponent( freshUUIDName(in: parentFolder, avoiding: []), isDirectory: true ) do { try FileManager.default.createDirectory(at: candidate, withIntermediateDirectories: false) } catch { throw BoardWriteError( operation: operation, path: candidate.path, reason: .io(message: "could not create folder: \(error.localizedDescription)") ) } return candidate } /// A fresh lowercase-UUIDv4 folder *name* for `parentFolder` — the folder-naming convention /// itself (01-storage-format.md § Fractal layout ▸ Rules, "Folder names are lowercase /// UUIDv4"). `UUID().uuidString` is uppercase; `.lowercased()` is the app's **emission** /// rule — accept liberally, emit conservatively. The loader's gate /// (`BoardLoader.isUUIDShaped`) accepts either case, so this lowercasing is a convention the /// app holds itself to, not something a reader depends on. /// /// Two exclusions, both re-minted rather than assumed away: a name already on disk in /// `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, `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. private static func freshUUIDName(in parentFolder: URL, avoiding taken: Set) -> String { var name: String repeat { name = UUID().uuidString.lowercased() } while taken.contains(name) || FileManager.default.fileExists(atPath: parentFolder.appendingPathComponent(name).path) return name } /// The folder-exists check every operation runs before touching the filesystem any further — /// shared instead of folded into its callers because "does this folder exist" has nothing to /// do with siblings, minting, or moving, and inlining it would bury the one thing a caller /// most needs to see at a glance: a missing or wrong-shaped folder is rejected before /// anything else happens. `role` names it the way the failing user action would ("parent /// folder", "item folder") — the error goes straight into the write-failure banner. private static func checkIsDirectory( _ url: URL, describedAs role: String, operation: WriteOperation ) throws(BoardWriteError) { var isDirectory: ObjCBool = false guard FileManager.default.fileExists(atPath: url.path, isDirectory: &isDirectory) else { throw BoardWriteError(operation: operation, path: url.path, reason: .unreadable(message: "\(role) does not exist")) } guard isDirectory.boolValue else { throw BoardWriteError(operation: operation, path: url.path, reason: .unreadable(message: "\(role) is not a directory")) } } // MARK: - Renumber /// Renumbers a parent's visible children to whole multiples of 1024 — the renumber fallback /// for exhausted midpoint precision (01-storage-format.md § Ordering), and **the sole /// exception to "a reorder rewrites only the moved item"**. Uniform across levels: the /// parent is a lane (renumbering its cards) or the board root (renumbering its lanes). /// /// - **Runs over loaded, valid children** (`visibleSiblings(of:operation:)`): one that fails /// to parse, or that lacks a usable `order`, fails the whole operation before anything is /// written. A renumber is bookkeeping inside a user action that already succeeded in /// principle — it must not be the thing that discovers a broken sibling halfway through /// rewriting the lane. /// - **Display order is the assignment order** (`Ranks.isOrderedForDisplay`: `order` /// ascending, folder name breaking ties) — the same rule the loader sorts by, so a /// renumber is guaranteed to be sequence-preserving: nothing visibly moves. /// /// Each child's rewrite is atomic; the batch is not. An interrupted renumber leaves some /// siblings renumbered and some not — every `order` still a valid float, display order /// still deterministic, and the next renumber finishes the job. That is the accepted cost /// noted in § Ordering, which the deterministic tie-break exists to make harmless. public static func renumberVisibleChildren(of parentFolder: URL) throws(BoardWriteError) { let operation = WriteOperation.renumberChildren let visible = try visibleSiblings(of: parentFolder, operation: operation, requireEditable: true) let ordered = Ranks.sortedForDisplay(visible, order: { $0.order }, name: { $0.folder.lastPathComponent }) for (child, rank) in zip(ordered, Ranks.renumbered(count: ordered.count)) { try updateIndex(inItemFolder: child.folder, operation: operation) { document in document.set(FrontmatterKeys.order, to: .double(rank)) } } } /// The strict visible-sibling scan shared by `renumberVisibleChildren` and the create /// operations' order assignment — extracted into one place so the two can never disagree /// about who counts as a sibling. Walks `parentFolder`'s UUID-shaped children holding an /// `index.md` (`BoardLoader.directoryCandidates`/`isUUIDShaped` — the same level-detection /// rule the loader uses), each parsed strictly: /// /// - **Tombstones are inert to ordering** (§ Deletion): a child whose `deleted` key is /// *present* is not counted — presence, not validity, is the test, exactly as /// `Lane`/`Card.isDeleted` reads it (an explicit `deleted: null` is absence to both). /// - **Strays are untouched**: non-UUID-shaped folders, and UUID-shaped folders without an /// `index.md` (an interrupted two-step create — the loader's own `.missingIndex` warning /// tolerates exactly this), are skipped here for the same reasons `BoardLoader` skips them. /// - A visible sibling's missing or malformed `order` fails the *whole* operation, naming /// that sibling's file, before anything is written — the same discover-before-you-write /// guarantee `renumberVisibleChildren`'s batch depends on. /// - `requireEditable` scopes the readable-but-uneditable pre-flight to the caller that /// will actually *rewrite* the siblings: renumber passes `true` (it must not discover an /// unwritable sibling halfway through the batch), the creates pass `false` — a create /// only *reads* its siblings' orders, and a flow-mapping sibling that loads and renders /// normally (01-storage-format.md § Frontmatter) must not block creating a new item /// beside it. private static func visibleSiblings( of parentFolder: URL, operation: WriteOperation, requireEditable: Bool ) throws(BoardWriteError) -> [(folder: URL, order: Double)] { let candidates: [URL] do { candidates = try BoardLoader.directoryCandidates(in: parentFolder) } catch { throw BoardWriteError( operation: operation, path: parentFolder.path, reason: .unreadable(message: error.description) ) } var visible: [(folder: URL, order: Double)] = [] for folder in candidates where BoardLoader.isUUIDShaped(folder.lastPathComponent) { let indexURL = folder.appendingPathComponent(BoardLoader.indexFileName) guard FileManager.default.fileExists(atPath: indexURL.path) else { continue } let document = try readDocument(at: indexURL, operation: operation) guard document.deleted.isMissing else { continue } if requireEditable { try checkEditable(document, at: indexURL, operation: operation) } switch document.order { case .missing: throw BoardWriteError( operation: operation, path: indexURL.path, reason: .unreadable(message: "missing required 'order' field") ) case let .malformed(raw): throw BoardWriteError( operation: operation, path: indexURL.path, reason: .unreadable(message: "malformed 'order' field: \(raw)") ) case let .valid(order): visible.append((folder: folder, order: order)) } } return visible } // MARK: - Move /// Moves a lane or card — and the whole tree beneath it — under another parent, in the same /// board or across boards. **A move is a physical folder move and the UUID travels /// unchanged** (01-storage-format.md § Fractal layout ▸ Rules, "Identity lifecycle: moves /// keep the UUID, copies mint fresh ones"): nothing below the moved root is read or /// rewritten, so nested cards, `attachments/`, and strays arrive byte-identical by /// construction rather than by copying carefully. /// /// Exactly one file is rewritten — the moved root's `index.md`, and only to carry its new /// `order` (§ Ordering, "a reorder rewrites only the moved item's `index.md`"), stamped and /// `modified-by`-cleared like every other app write. `order` is the caller's explicit rank /// (a drop between two siblings), or `nil` to append after the destination's visible /// siblings. /// /// **The import boundary is the one place within-board uniqueness is enforced** (§ Fractal /// layout ▸ Rules). `sourceBoardRoot` and `destinationBoardRoot` are compared by resolved, /// standardized path; when they differ, this is an import, and an arriving UUID that already /// exists anywhere in the destination board — **tombstones included, because they are on /// disk** — is degraded to a copy: a fresh UUID is minted for that folder and nothing else. /// Degradation is **per folder, at the finest grain**: a lane arriving with one colliding /// card is still a lane *move*, and only that card arrives reminted. A remint is an identity /// repair, not an edit — the folder is renamed and its files' bytes are never touched, so /// the reminted item's `modified`, `modified-by`, and history are exactly what the source /// had. Every repair is reported in `MoveResult.reminted`; a same-board move never remints, /// and the same UUID living in two boards is not an error anywhere else — boards are /// **independent identity namespaces**, and forks only ever meet here. /// /// The order of operations is the contract: /// /// 1. **Validate before anything else**: source and destination parent must be existing /// directories, and the source must be UUID-shaped — this writer moves lanes and cards, /// and a stray is not one (§ Fractal layout ▸ Rules, "Name shape gates level detection"). /// 2. **Pre-flight the moved root's editability** (`readDocument` + `checkEditable`): the /// move must rewrite that file at the destination, so a file that cannot be round-tripped /// refuses *before* the folder moves — discover-before-you-write, the same guarantee /// `renumberVisibleChildren` depends on. Only the root needs it: a move never rewrites a /// nested file, so an uneditable card inside a moved lane is none of this call's business. /// 3. **Compute the destination `order` before the move**, off the destination's visible /// siblings — while the moved item is still elsewhere and so cannot count itself. /// 4. **Scan the destination board's identities** (depth 1 and 2, `directoryCandidates` + /// `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:)` / /// `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 /// rather than moving and then renaming: one filesystem operation instead of two, and it /// is also the only way the repair works when the collision is a sibling sitting in the /// very destination parent — a plain move onto an existing name fails outright. /// 6. **Repair the arrived tree's children** — the moved root's direct UUID-shaped children, /// which is every level a compound arrival can carry (a lane's cards; a card has no /// UUID-shaped children). /// 7. **Rewrite the moved root's `index.md`** with the rank from step 3. /// /// **Atomic per filesystem operation, not per gesture** — the accepted edge. A rename that /// fails mid-repair, or the `updateIndex` that fails once the folder has already arrived, /// leaves the item at the destination with a stale `order` and possibly a partly repaired /// tree. Every value on disk is still valid, the failure is surfaced verbatim through the /// thrown error (02-architecture.md § Write-failure surfacing), and the caller's reload /// shows the true state — nothing is silently retried or rolled back behind the user's back. /// /// **A move whose destination is the item's current parent degrades to a plain reorder** — /// no folder to move, no boundary to cross, just the `order` rewrite (step 7) with the /// appended rank computed over the siblings *excluding the item itself*, which unlike every /// other move is already there to be miscounted. A drop that lands back in its own lane is /// the same gesture as one that lands elsewhere; the writer doing the right degenerate /// thing keeps the API total instead of leaking a `FileManager` name collision. public static func moveItem( at sourceFolder: URL, toParent destinationParent: URL, sourceBoardRoot: URL, destinationBoardRoot: URL, order: Double? ) throws(BoardWriteError) -> MoveResult { let sourceName = sourceFolder.lastPathComponent // Which case this is — an ordinary move, or the same-parent degenerate reorder — is // decided from the two URLs alone, before anything on disk is even looked at, so the // right vocabulary word is in hand for every pre-flight check that follows rather than // being retrofitted once the branch below is reached. let isReorder = isSameLocation(sourceFolder.deletingLastPathComponent(), destinationParent) var operation: WriteOperation = isReorder ? .reorder(title: nil) : .move(title: nil) try checkIsDirectory(sourceFolder, describedAs: "item folder", operation: operation) try checkIsDirectory(destinationParent, describedAs: "destination parent folder", operation: operation) try checkIsUUIDShaped(sourceFolder, operation: operation) // The pre-flight's own read is where this move/reorder learns the moved root's title; // every failure from here on in this call reuses the enriched value. operation = try checkIndexIsRewritable(inItemFolder: sourceFolder, operation: operation) if isReorder { let rank: Double if let order { rank = order } else { let siblings = try visibleSiblings(of: destinationParent, operation: operation, requireEditable: false) // "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 = IntegrityRules.canonicalIdentity(sourceName) rank = Ranks.append( toVisible: siblings .filter { IntegrityRules.canonicalIdentity($0.folder.lastPathComponent) != selfIdentity } .map(\.order) ) } try updateIndex(inItemFolder: sourceFolder, operation: operation) { document in document.set(FrontmatterKeys.order, to: .double(rank)) } return MoveResult(id: ItemID(rawValue: sourceName), reminted: []) } let rank = try destinationOrder(order, inParent: destinationParent, operation: operation) let isImport = !isSameLocation(sourceBoardRoot, destinationBoardRoot) let existing = isImport ? identities(inBoard: destinationBoardRoot) : [] var reserved = existing var reminted: [MoveResult.Remint] = [] var arrivedName = 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))) } let arrivedRoot = destinationParent.appendingPathComponent(arrivedName, isDirectory: true) do { try FileManager.default.moveItem(at: sourceFolder, to: arrivedRoot) } catch { throw BoardWriteError( operation: operation, path: sourceFolder.path, reason: .io(message: "could not move folder: \(error.localizedDescription)") ) } // The old→new pair. The `updateIndex` below then supersedes the arrived `index.md`'s own // receipt, which is the truth about the file the reload will read. EchoLedger.current?.recordMove(from: sourceFolder, to: arrivedRoot) if isImport { let children = childCandidates(of: arrivedRoot) 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) reminted.append(MoveResult.Remint( from: ItemID(rawValue: child.lastPathComponent), to: ItemID(rawValue: fresh) )) } } try updateIndex(inItemFolder: arrivedRoot, operation: operation) { document in document.set(FrontmatterKeys.order, to: .double(rank)) } return MoveResult(id: ItemID(rawValue: arrivedName), reminted: reminted) } /// Every identity a board already holds: its UUID-shaped lane folders and their UUID-shaped /// card folders — the two depths where identity lives (§ Fractal layout, "Level is /// position"). **Tombstoned items count**: a tombstone is a live folder on disk carrying a /// `deleted:` key, and arriving on top of one would be exactly the duplicate the import /// boundary exists to prevent (a Put Back would then resurrect a twin). Strays do not count /// — they are not identities at all — and neither does anything deeper: a card has no /// UUID-shaped children, and a UUID-shaped folder under `attachments/` is content, not a /// level. /// /// Reads through the loader's own door (`directoryCandidates`), so the writer's idea of what /// a board contains can never drift from the loader's. An unreadable folder yields no /// candidates rather than failing: the same degradation the loader applies below the root, /// 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 (`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 /// verbatim set would miss exactly that collision and let a duplicate UUID into the board. private static func identities(inBoard boardRoot: URL) -> Set { Set(identityOccurrences(inBoard: boardRoot)) } /// The same walk as a **bag rather than a set** — every identity-bearing folder's canonical name, /// duplicates included, which is what lets the duplicate-id remint ask "does anything else still /// carry this identity" instead of merely "is it present" (`remintDuplicateIdentity`). /// /// The two exist as one walk deliberately: a re-verify that read the board differently from the /// collision probe would be a second definition of "what this board contains". private static func identityOccurrences(inBoard boardRoot: URL) -> [String] { var identities: [String] = [] for lane in childCandidates(of: boardRoot) { identities.append(IntegrityRules.canonicalIdentity(lane.lastPathComponent)) for card in childCandidates(of: lane) { identities.append(IntegrityRules.canonicalIdentity(card.lastPathComponent)) } } // **The trash counts.** Board-wide uniqueness spans both containers (01-storage-format.md // § Fractal layout ▸ Rules — "Duplicate ids within a board are never tolerated"), and a // trashed card is an ordinary card in a special place: arriving on top of one would put // two folders with one identity in the board, and the moment the user dragged the trashed // one back out the snapshot would carry the duplicate the loader is forbidden to hold. // 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.append(IntegrityRules.canonicalIdentity(card.lastPathComponent)) } return identities } /// 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" /// this writer is allowed to have. private static func childCandidates(of folder: URL) -> [URL] { let candidates = (try? BoardLoader.directoryCandidates(in: folder)) ?? [] return candidates.filter { BoardLoader.isUUIDShaped($0.lastPathComponent) } } /// Renames a folder in place, keeping its parent — the whole of an identity repair, and of a /// copy's remint. The folder's contents, `index.md` included, are never opened. private static func renameFolder( _ folder: URL, toSiblingNamed name: String, operation: WriteOperation ) throws(BoardWriteError) { let destination = folder.deletingLastPathComponent().appendingPathComponent(name, isDirectory: true) do { try FileManager.default.moveItem(at: folder, to: destination) } catch { throw BoardWriteError( operation: operation, path: folder.path, reason: .io(message: "could not rename folder: \(error.localizedDescription)") ) } // A remint is a folder move like any other as far as provenance goes — the identity // changed, so the arriving item is a different item, and the pair says where it came from. EchoLedger.current?.recordMove(from: folder, to: destination) } /// Whether two URLs name the same place on disk — symlinks resolved, `..`/`.` standardized /// away. The import boundary turns on this one comparison, so it is deliberately about /// *location*, not spelling: `/tmp/B.kanban` and `/private/tmp/B.kanban/.` are one board, /// and treating them as two would remint every arriving item for nothing. private static func isSameLocation(_ lhs: URL, _ rhs: URL) -> Bool { lhs.resolvingSymlinksInPath().standardizedFileURL.path == rhs.resolvingSymlinksInPath().standardizedFileURL.path } // MARK: - Copy /// Copies a lane or card — and the whole tree beneath it — under another parent, minting /// **fresh UUIDs for every folder it materializes** (01-storage-format.md § Fractal layout ▸ /// Rules, "Identity lifecycle: moves keep the UUID, copies mint fresh ones"). The copy is a /// new identity at every level: a copied lane's cards are new cards, not the same cards seen /// twice. Returns the new root's identity. This is the item-level copy — ⌘C/⌘V, ⌥-drag /// duplicate, the cross-board drag default; whole-board copies (Save as Template, File ▸ /// Duplicate) keep their GUIDs and are not this call. /// /// Everything travels verbatim: `attachments/` and its subfolders, strays, bodies, unknown /// keys, comments, line endings. Only the schema's provenance stamps move, per `stamps`: /// /// - `.fork` — an ordinary copy **keeps `created`** (it is a fork of something that really /// was created then) while `modified` is stamped and `modified-by` cleared, because a /// paste or a duplicate is an app write (§ Frontmatter). /// - `.born` — template instantiation stamps `created` **and** `modified` fresh, from one /// `Date` for the whole tree: a card made from a template is born today, not forked from /// the template (09-templates.md). /// /// Two deliberate leniencies below the root, both of them "what a hand copy would do": /// /// - A nested `index.md` that is **readable-but-uneditable** (§ Frontmatter), or that cannot /// be read at all, is copied byte-verbatim and simply not stamped. Refusing an entire copy /// because one nested card is a flow mapping would be hostile, and the file arrives /// *exactly* as it was rather than corrupted — its stale `modified-by` attribution /// surviving is the self-reported-provenance honest limit § Frontmatter already /// acknowledges. The **root** gets no such leniency: it must be rewritten (it needs its /// new `order`), so an unreadable or uneditable root refuses the copy up front, before /// anything is materialized. /// - A nested UUID-shaped folder with **no `index.md`** — interrupted-create residue — is /// copied and reminted like any other, and not rewritten: the same skip the loader applies /// to it (`.missingIndex`). /// /// **All-or-nothing at the destination**, unlike a move: any failure once copying has begun /// removes the partially copied tree best-effort and rethrows, because a half-copied item is /// pure residue — nothing was there before, so there is no true state for a reload to show. /// The source is never touched on any path. public static func copyItem( at sourceFolder: URL, toParent destinationParent: URL, order: Double?, stamps: CopyStamps ) throws(BoardWriteError) -> ItemID { var operation: WriteOperation = .copy(title: nil) try checkIsDirectory(sourceFolder, describedAs: "item folder", operation: operation) try checkIsDirectory(destinationParent, describedAs: "destination parent folder", operation: operation) try checkIsUUIDShaped(sourceFolder, operation: operation) // The pre-flight's own read is where this copy learns the source root's title; every // failure from here on in this call — including inside the materialized-but-not-yet- // stamped tree below — reuses the enriched value. operation = try checkIndexIsRewritable(inItemFolder: sourceFolder, operation: operation) let rank = try destinationOrder(order, inParent: destinationParent, operation: operation) let rootName = freshUUIDName(in: destinationParent, avoiding: []) let root = destinationParent.appendingPathComponent(rootName, isDirectory: true) do { try FileManager.default.copyItem(at: sourceFolder, to: root) } catch { try? FileManager.default.removeItem(at: root) throw BoardWriteError( operation: operation, path: sourceFolder.path, reason: .io(message: "could not copy folder: \(error.localizedDescription)") ) } do { var copied: [URL] = [] try remintDescendants(of: root, collecting: &copied, operation: operation) let now = Date() try updateIndex(inItemFolder: root, operation: operation) { document in if case .born = stamps { document.set(FrontmatterKeys.created, to: .date(now)) } document.set(FrontmatterKeys.order, to: .double(rank)) } for folder in copied { try stampCopiedDescendant(at: folder, stamps: stamps, now: now, operation: operation) } } catch { try? FileManager.default.removeItem(at: root) throw error } return ItemID(rawValue: rootName) } /// Renames every UUID-shaped folder beneath `folder` to a fresh mint, depth first, and /// collects where they ended up — "copies mint fresh UUIDs for **every** folder they /// materialize", which for a copied lane means its cards too. Recursion rather than a /// two-level walk because the rule is about folders, not levels; non-UUID-shaped folders /// (`attachments/` and anything under it, strays) are neither renamed nor descended into, /// exactly as the loader treats them. /// /// Each child is renamed *before* being descended into, so the collected URLs are the final /// ones — a caller can stamp them without re-deriving any path. The mint only has to avoid /// what is on disk in that folder: the siblings not yet renamed are still there under their /// original names, and `freshUUIDName` checks for exactly that. /// /// **Internal rather than `private`**: template instantiation (`TemplateEngine`) is the same /// remint one level up — "mint fresh GUIDs for every lane/card folder" (09-templates.md /// ▸ Instantiation) is `copyItem`'s rule applied to a whole board rather than to one item, and /// pointing this at a copied *board root* is literally that. A second implementation of "which /// folders are identities" is exactly what must not exist. static func remintDescendants( of folder: URL, collecting copied: inout [URL], operation: WriteOperation ) throws(BoardWriteError) { for child in childCandidates(of: folder) { let fresh = freshUUIDName(in: folder, avoiding: []) try renameFolder(child, toSiblingNamed: fresh, operation: operation) let renamed = folder.appendingPathComponent(fresh, isDirectory: true) copied.append(renamed) try remintDescendants(of: renamed, collecting: &copied, operation: operation) } } /// Stamps one copied folder below the root — best-effort by design (see `copyItem`): a /// missing, unreadable, or uneditable `index.md` is left exactly as the copy found it rather /// than failing the gesture. `order` is not touched: a nested item keeps its rank among its /// own siblings, which travelled with it. /// /// **Internal rather than `private`**, with `remintDescendants` and for its reason: an /// instantiated board's lanes and cards are stamped by this exact rule, leniency included. static func stampCopiedDescendant( at folder: URL, stamps: CopyStamps, now: Date, operation: WriteOperation ) throws(BoardWriteError) { let indexURL = folder.appendingPathComponent(BoardLoader.indexFileName) guard FileManager.default.fileExists(atPath: indexURL.path), let copied = try? readDocument(at: indexURL, operation: operation), copied.uneditableShape == nil else { return } try updateIndex(inItemFolder: folder, operation: operation) { document in if case .born = stamps { document.set(FrontmatterKeys.created, to: .date(now)) } } } /// Materializes an item from **supplied `index.md` text** rather than from a folder on disk — /// the clipboard's staging-less fallback (04-interactions.md ▸ Clipboard: "if the staged /// snapshot is missing or unreadable at paste time, paste falls back to the embedded /// `index.md` — content intact, attachments absent"). /// /// **The text is written byte-faithfully, because it *is* the source bytes.** It was captured /// verbatim at copy time and travels through the manifest untouched, so this call writes it as /// given — unknown keys, comments, blank lines, line endings, body and all — rather than /// re-serializing anything. That is the round-trip guarantee applied to a file the app is /// minting from bytes it was handed (01-storage-format.md § Fractal layout ▸ Rules). /// /// **Fresh identity, fork stamps** — the same semantics `copyItem` gives an ordinary copy, and /// necessarily so: this is a copy that happened to arrive as text. Every folder is a fresh mint, /// `created` survives in the supplied bytes (a duplicate is a fork), and the root's `order` and /// `modified` are rewritten by the closing `updateIndex`, which also clears `modified-by`. /// /// `children` are a **lane's** cards, each its own supplied `index.md`, materialized under the /// new root in the order given and deliberately *not* rewritten: "a nested item keeps its rank /// among its own siblings, which travelled with it" (`copyItem`'s rule). A card passes none. /// /// **The root gets `copyItem`'s strictness and the children get its leniency.** The root must be /// rewritten — it needs its new `order` — so unparseable or uneditable text fails the call; /// a child is never rewritten, so whatever it is arrives exactly as it was. /// /// **All-or-nothing at the destination**, `copyItem`'s rule for its reason: any failure once the /// folder exists removes the partial tree best-effort and rethrows, because a half-materialized /// item is pure residue — nothing was there before. public static func materializeItem( inParent destinationParent: URL, indexText: String, children: [String] = [], order: Double? ) throws(BoardWriteError) -> ItemID { let operation = WriteOperation.copy(title: nil) try checkIsDirectory(destinationParent, describedAs: "destination parent folder", operation: operation) let rank = try destinationOrder(order, inParent: destinationParent, operation: operation) // The same mint the create path uses, so every identity this app materializes is materialized // one way — fresh lowercase UUIDv4, folder and all. let root = try mintUUIDFolder(in: destinationParent, operation: operation) do throws(BoardWriteError) { try atomicReplace( text: indexText, at: root.appendingPathComponent(BoardLoader.indexFileName), operation: operation ) for child in children { let childFolder = try mintUUIDFolder(in: root, operation: operation) try atomicReplace( text: child, at: childFolder.appendingPathComponent(BoardLoader.indexFileName), operation: operation ) } try updateIndex(inItemFolder: root, operation: operation) { document in document.set(FrontmatterKeys.order, to: .double(rank)) } } catch { try? FileManager.default.removeItem(at: root) throw error } return ItemID(rawValue: root.lastPathComponent) } // MARK: - The materialized trash /// `/.trash/` — the board's trash container, named but not created. /// One place, so the loader's walk and every write below can never disagree about where the /// trash is (the name itself is `BoardLoader.trashFolderName`, which is where the reserved-name /// rule lives). static func trashFolder(inBoard boardRoot: URL) -> URL { boardRoot.appendingPathComponent(BoardLoader.trashFolderName, isDirectory: true) } /// **Deleting a card: a physical move into `/.trash/`** (01-storage-format.md /// § Deletion, resettled 2026-07-28; 03-board-ui.md § Trash). The tombstone is retired — no /// key is written, nothing is flagged, and the card becomes "an ordinary card in a special /// place". /// /// The sequence, which is the contract: /// /// 1. **`cardFolder` must be a card** (`checkIsCardFolder`, the stricter guard: UUID-shaped /// *under* a UUID-shaped parent). This is what makes "lanes are never trashed" structural /// rather than a policy the caller has to remember — a lane, a board root and a stray are /// all refused here, and lane deletion has its own call (`removeLane`). /// 2. **Pre-flight the card's `index.md`** (`checkIndexIsRewritable`) — the move rewrites it /// at the destination, so a file that cannot be round-tripped refuses *before* the folder /// travels. `moveItem`'s discover-before-you-write rule, for its reason. /// 3. **`.trash/` is created if absent** — it is minted by the first delete, so most boards /// meet it here. /// 4. **Move the folder.** Nothing beneath it is read or rewritten, so `attachments/`, strays /// and every byte arrive unchanged, exactly as in an ordinary move. /// 5. **Rewrite `order` to `order`, and stamp.** /// /// **`order` is the caller's, always** — deliberately not defaulted and deliberately not /// computed here. Entry is at the *top* ("every arrival … lands at the trash's topmost /// position, minting an `order` rank above the current top"), which is /// `Ranks.insertAtHead(ofVisible:)` over the trash's current ranks — a question about the /// *snapshot*, which the store holds and this stateless layer does not. Value-passing keeps /// the seam: the Writer takes a rank, the store computes it. /// /// **The `modified` stamp is the point, not a side effect.** Deletion is the one exception to /// moves-don't-stamp — "deletion is an edit to the card's story" — and the stamp is what a /// future age-based auto-purge reads. It falls out of `updateIndex` here rather than being /// asked for, which is why there is nothing extra in step 5. /// /// **Collision inside `.trash/` is impossible by construction**, and it is checked anyway. The /// card is a resident of this very board, and board-wide uniqueness now spans lanes *and* the /// trash (`identities(inBoard:)`), so no folder of that name can already be in there — the /// import boundary reminted any arriving twin before it ever landed. Should one exist regardless /// (a hand copy, an interrupted move), the move fails loudly through `FileManager` rather than /// clobbering it: this call never remints, because the identity is exactly what a later restore /// and the undo stack are holding on to. /// /// - Returns: the card's identity, unchanged — a delete moves a folder, it does not rename one. @discardableResult public static func deleteCardToTrash( at cardFolder: URL, inBoard boardRoot: URL, order: Double ) throws(BoardWriteError) -> ItemID { try moveCardIntoTrash( at: cardFolder, inBoard: boardRoot, order: order, operation: .delete(title: nil), removingLegacyKey: false ) } /// **Migrating a legacy tombstoned card**: the same physical move into `.trash/`, plus the /// surgical removal of the `deleted:` key that put it there (01-storage-format.md § Deletion: /// "a card carrying `deleted:` is relocated into `.trash/` (key removed)"). /// /// `deleteCardToTrash` with one extra edit, and written as such rather than as a parameter on /// the public delete: the two are different events with different vocabulary — one is the user /// pressing ⌫, the other is the app tidying a board written by an older version — and a /// failure must say which (`WriteOperation.migrateTombstone`). /// /// The removal is `FrontmatterDocument.remove`, so it takes **every** occurrence of the key: /// a hand-duplicated `deleted:` line cannot leave a twin behind that would re-migrate the card /// on the next load. Nothing else in the file is touched — unknown keys, comments, blank lines, /// line endings and the body are the same bytes they were, and `order` and the stamps are the /// only writes, exactly as for an ordinary delete. @discardableResult public static func migrateTombstonedCard( at cardFolder: URL, inBoard boardRoot: URL, order: Double ) throws(BoardWriteError) -> ItemID { try moveCardIntoTrash( at: cardFolder, inBoard: boardRoot, order: order, operation: .migrateTombstone(title: nil), removingLegacyKey: true ) } /// The shared body of `deleteCardToTrash` and `migrateTombstonedCard` — see the former for the /// sequence and the latter for what `removingLegacyKey` adds. private static func moveCardIntoTrash( at cardFolder: URL, inBoard boardRoot: URL, order: Double, operation initialOperation: WriteOperation, removingLegacyKey: Bool ) throws(BoardWriteError) -> ItemID { var operation = initialOperation try checkIsDirectory(cardFolder, describedAs: "card folder", operation: operation) try checkIsDirectory(boardRoot, describedAs: "board folder", operation: operation) try checkIsCardFolder(cardFolder, operation: operation) operation = try checkIndexIsRewritable(inItemFolder: cardFolder, operation: operation) let trash = trashFolder(inBoard: boardRoot) do { try FileManager.default.createDirectory(at: trash, withIntermediateDirectories: true) } catch { throw BoardWriteError( operation: operation, path: trash.path, reason: .io(message: "could not create the trash folder: \(error.localizedDescription)") ) } let name = cardFolder.lastPathComponent let arrived = trash.appendingPathComponent(name, isDirectory: true) do { try FileManager.default.moveItem(at: cardFolder, to: arrived) } catch { throw BoardWriteError( operation: operation, path: cardFolder.path, reason: .io(message: "could not move folder into the trash: \(error.localizedDescription)") ) } // A delete is a move into `.trash/` on disk (01-storage-format.md § Deletion), so the // receipt is the move pair — and it reads correctly from either end: the board side sees an // absence where the card was, the shown-trash side sees an arrival where it went. EchoLedger.current?.recordMove(from: cardFolder, to: arrived) try updateIndex(inItemFolder: arrived, operation: operation) { document in document.set(FrontmatterKeys.order, to: .double(order)) if removingLegacyKey { 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) } /// **Migrating a legacy tombstoned lane**: the `deleted:` key is removed and the lane returns /// **live**, exactly where it always was (01-storage-format.md § Deletion: "a lane carrying /// `deleted:` returns live with the key removed and a notice — resurrection is the safe /// direction, nothing is destroyed by migration"). /// /// **Nothing moves and nothing is removed.** There is no lane trash to move it into, and /// destroying a lane the user may never have meant to lose is the one direction migration is /// forbidden to take. Its cards come back with it; any of *them* carrying their own /// `deleted:` key migrate on their own account, as ordinary tombstoned cards. /// /// The lane's rank is untouched, so it returns to its own position among its siblings — /// position-perfect for the same reason the retired Put Back was: the folder never moved. /// /// Refuses anything that is not a lane (`checkIsLaneFolder`): a card's migration is a move and /// has its own call, and pointing this at one would strip the key while leaving the card /// exactly where the tombstone had hidden it. public static func migrateTombstonedLane(at laneFolder: URL) throws(BoardWriteError) { let operation = WriteOperation.migrateTombstone(title: nil) try checkIsDirectory(laneFolder, describedAs: "lane folder", operation: operation) try checkIsLaneFolder(laneFolder, operation: operation) 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 /// (01-storage-format.md § Deletion; 03-board-ui.md § Trash: "Cards only. Lanes are never /// trashed"). There is no lane trash and no tombstone; the recovery net is native undo /// in-session and git history on git boards. /// /// **Capture before you remove.** Undo restores a lane by replaying its bytes, which only /// works if someone is holding them — `captureSubtree(at:operation:)` is that primitive, and /// the pairing is the caller's (the undo step captures, then calls this). Deliberately not /// folded in here: a purge that always paid for a full tree read would make Empty Trash on a /// large board slow for a recovery nothing was going to use. /// /// **A folder that is already gone is success**, `purgeItem`'s rule and for its reason: a /// Finder deletion converges on exactly the end state this produces, so there is nothing left /// to distinguish. /// /// Refuses anything that is not a lane (`checkIsLaneFolder`) — a board root, a card, a stray, /// and notably a *trash card*, whose parent is `.trash/` rather than the board root. public static func removeLane(at laneFolder: URL) throws(BoardWriteError) { let operation = WriteOperation.delete(title: nil) guard FileManager.default.fileExists(atPath: laneFolder.path) else { return } try checkIsLaneFolder(laneFolder, operation: operation) do { try FileManager.default.removeItem(at: laneFolder) } catch { throw BoardWriteError( operation: operation, path: laneFolder.path, reason: .io(message: "could not remove folder: \(error.localizedDescription)") ) } // The absence marker — and it takes the lane's cards' receipts with it, which is what makes // the digest's implied-events rule and the ledger agree that this was one event. EchoLedger.current?.recordDeletion(at: laneFolder) } /// Permanently removes one card from the trash — the trash's **Delete / Delete Immediately** /// (03-board-ui.md § Trash: "on a trash card, Delete (⌫/⌘⌫) is permanent"). /// /// `purgeItem` with the container checked: the folder must actually sit in this board's /// `.trash/`, so a mis-aimed permanent delete cannot reach a live card. Delete Immediately /// *from* the board — which skips the trash — is `purgeItem`, not this call. /// /// An already-gone folder is success, `purgeItem`'s rule. public static func purgeTrashCard(at cardFolder: URL, inBoard boardRoot: URL) throws(BoardWriteError) { let operation = WriteOperation.purge(title: nil) guard FileManager.default.fileExists(atPath: cardFolder.path) else { return } try checkIsUUIDShaped(cardFolder, operation: operation) guard isSameLocation(cardFolder.deletingLastPathComponent(), trashFolder(inBoard: boardRoot)) else { throw BoardWriteError( operation: operation, path: cardFolder.path, reason: .unreadable(message: "folder is not in this board's trash") ) } do { try FileManager.default.removeItem(at: cardFolder) } catch { throw BoardWriteError( operation: operation, path: cardFolder.path, reason: .io(message: "could not remove folder: \(error.localizedDescription)") ) } EchoLedger.current?.recordDeletion(at: cardFolder) } /// **Empty Trash** (⇧⌘⌫, 03-board-ui.md § Trash): permanently removes every card in /// `/.trash/`. Returns what it removed, in folder-name order. /// /// **The card folders, not the container.** The design says it "purges the whole `.trash/`", /// and the cards are the whole of it in every board the app produces — but the container is a /// real folder a hand-editor can put things in, and stray tolerance ("preserved verbatim, /// never rendered") does not stop applying because the folder is the app's. Removing only what /// the loader recognizes as a card keeps the count honest (the confirmation names cards) and /// keeps this command from being the one place in the app that destroys a file nobody ever /// saw. The emptied container is left standing; the next delete would only recreate it. /// /// **Search-independent**, by construction: this walks the folder, never a filtered view. /// /// Removal is per card, in order, and a failure stops the batch and throws — everything /// already removed stays removed, `importAttachments`' rule. A board with no trash at all /// removes nothing and returns `[]`. @discardableResult public static func emptyTrash(inBoard boardRoot: URL) throws(BoardWriteError) -> [ItemID] { let operation = WriteOperation.purge(title: nil) var purged: [ItemID] = [] for card in childCandidates(of: trashFolder(inBoard: boardRoot)) { do { try FileManager.default.removeItem(at: card) } catch { throw BoardWriteError( operation: operation, path: card.path, reason: .io(message: "could not remove folder: \(error.localizedDescription)") ) } EchoLedger.current?.recordDeletion(at: card) purged.append(ItemID(rawValue: card.lastPathComponent)) } return purged } /// Refuses any folder that is not a **lane**: UUID-shaped, directly under a board root. /// /// The mirror of `checkIsCardFolder`, and it needs one clause that one does not. A lane is /// `/` and a card is `//`, so "parent is not UUID-shaped" tells /// the two apart — except that a **trash card** is `/.trash/`, whose parent is not /// UUID-shaped either. Naming the container explicitly is what keeps a permanent delete of a /// trashed card from being reachable through the lane-delete door. private static func checkIsLaneFolder(_ folder: URL, operation: WriteOperation) throws(BoardWriteError) { try checkIsUUIDShaped(folder, operation: operation) let parentName = folder.deletingLastPathComponent().lastPathComponent guard !BoardLoader.isUUIDShaped(parentName), parentName != BoardLoader.trashFolderName else { throw BoardWriteError( operation: operation, path: folder.path, reason: .unreadable(message: "folder is not a lane: only a lane is deleted whole") ) } } // MARK: - Subtree capture and replay /// Every byte of a folder tree, in memory — the capture half of a physical removal's undo /// (13-native-undo.md ▸ Rules; 03-board-ui.md § Trash, "the net is undo, not the trash"). /// /// **`readIndexText(ofItem:operation:)` widened from one file to a whole tree**, and for the /// same reason: the inverse of a physical removal is a recreation, and the only way ⌘Z can put /// a lane back *with its identity and its cards* is for the step to be holding what was there. /// One `index.md` is enough to replay a create; a lane delete takes its nested cards, their /// `attachments/`, and every stray with it. /// /// **Everything, verbatim.** Unlike every other walk in this file, this one does *not* apply /// the loader's stray exclusions: hidden files (`.DS_Store`, a dot-file a hand-editor left), /// non-UUID folders, files with no meaning to the schema — all captured, because the promise is /// that undo restores what was removed rather than what the app would have rendered. Bytes are /// carried as `Data` and never decoded, so encoding, line endings and BOMs are non-questions; /// POSIX permissions ride along per entry. /// /// **Symlinks are captured as links, never followed** (01-storage-format.md § Fractal layout ▸ /// Rules) — the destination string is recorded and recreated as a link, so a cyclic or /// cross-volume link is neither traversed here nor materialized as a copy of its target. /// /// Entries are sorted by name at every level, so a capture is a deterministic value: two /// captures of one unchanged tree are `==`, which is what makes a round-trip assertable. /// /// It reads the whole tree into memory, so it is for the sizes a board actually has (a lane /// and its cards) — not a general-purpose archiver. public static func captureSubtree( at folder: URL, operation: WriteOperation ) throws(BoardWriteError) -> SubtreeSnapshot { try checkIsDirectory(folder, describedAs: "item folder", operation: operation) let entries: [URL] do { entries = try FileManager.default.contentsOfDirectory( at: folder, includingPropertiesForKeys: [.isDirectoryKey, .isSymbolicLinkKey], options: [] ) } catch { throw BoardWriteError( operation: operation, path: folder.path, reason: .unreadable(message: "could not list folder: \(error.localizedDescription)") ) } var captured: [SubtreeSnapshot.Entry] = [] for entry in entries.sorted(by: { $0.lastPathComponent < $1.lastPathComponent }) { let name = entry.lastPathComponent let values = try? entry.resourceValues(forKeys: [.isDirectoryKey, .isSymbolicLinkKey]) if values?.isSymbolicLink == true { guard let destination = try? FileManager.default.destinationOfSymbolicLink(atPath: entry.path) else { throw BoardWriteError( operation: operation, path: entry.path, reason: .unreadable(message: "could not read symbolic link") ) } captured.append(.symlink(name: name, destination: destination)) } else if values?.isDirectory == true { captured.append(.folder(try captureSubtree(at: entry, operation: operation))) } else { let contents: Data do { contents = try Data(contentsOf: entry) } catch { throw BoardWriteError( operation: operation, path: entry.path, reason: .unreadable(message: "could not read file: \(error.localizedDescription)") ) } captured.append(.file(name: name, contents: contents, permissions: posixPermissions(of: entry))) } } return SubtreeSnapshot( name: folder.lastPathComponent, permissions: posixPermissions(of: folder), entries: captured ) } /// Puts a captured tree back, at `folder`, byte for byte — the replay half of /// `captureSubtree(at:operation:)`, and `recreateItem`'s rules one level of nesting wider. /// /// - **The parent must already exist** (`withIntermediateDirectories: false`): a redo whose /// board root has since gone must fail rather than conjure a tree in mid-air. /// - **It refuses to clobber**: anything at `folder` fails loudly rather than being written /// over. Restoring on top of a folder someone recreated meanwhile would silently merge two /// trees. /// - **The bytes are written verbatim** — nothing is stamped, nothing is re-serialized, no /// `index.md` is parsed. This replays; it does not edit. /// - **All-or-nothing**: any failure removes the partial tree best-effort and rethrows, the /// `materializeItem` rule — a half-restored lane is pure residue, since nothing was there. /// /// `folder`'s own name governs, not `snapshot.name`: a caller restoring to the path it removed /// passes the same URL, and the snapshot's name is carried for identification, not as an /// instruction. /// /// Permissions are applied **after** a folder's children are written, so a captured read-only /// directory does not lock out its own contents on the way back in. public static func recreateSubtree( at folder: URL, from snapshot: SubtreeSnapshot, operation: WriteOperation ) throws(BoardWriteError) { guard !FileManager.default.fileExists(atPath: folder.path) else { throw BoardWriteError( operation: operation, path: folder.path, reason: .io(message: "something already exists here") ) } do throws(BoardWriteError) { try materialize(snapshot, at: folder, operation: operation, intermediates: false) } catch { try? FileManager.default.removeItem(at: folder) throw error } } /// `recreateSubtree`'s recursion, minus its clobber refusal and its cleanup — both belong to /// the top-level call, which is the only one with a partial tree to remove. private static func materialize( _ snapshot: SubtreeSnapshot, at folder: URL, operation: WriteOperation, intermediates: Bool ) throws(BoardWriteError) { do { try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: intermediates) } catch { throw BoardWriteError( operation: operation, path: folder.path, reason: .io(message: "could not create folder: \(error.localizedDescription)") ) } for entry in snapshot.entries { switch entry { case let .file(name, contents, permissions): let fileURL = folder.appendingPathComponent(name) do { try contents.write(to: fileURL) // An undo restore recreates whole subtrees byte for byte; the bytes are already // in hand, so every restored file gets its own receipt rather than only the // `index.md` at the top. EchoLedger.current?.recordWrite(at: fileURL, data: contents) } catch { throw BoardWriteError( operation: operation, path: fileURL.path, reason: .io(message: "could not write file: \(error.localizedDescription)") ) } setPosixPermissions(permissions, of: fileURL) case let .folder(child): try materialize( child, at: folder.appendingPathComponent(child.name, isDirectory: true), operation: operation, intermediates: false ) case let .symlink(name, destination): let linkURL = folder.appendingPathComponent(name) do { try FileManager.default.createSymbolicLink(atPath: linkURL.path, withDestinationPath: destination) } catch { throw BoardWriteError( operation: operation, path: linkURL.path, reason: .io(message: "could not create symbolic link: \(error.localizedDescription)") ) } } } // After the children, so a captured read-only folder cannot lock out its own contents. setPosixPermissions(snapshot.permissions, of: folder) } /// An item's POSIX permission bits, or `nil` when they cannot be read — `attributesOfItem` /// rather than a `URLResourceValues` key because it is `lstat`-based, so a symlink's own /// attributes are never its target's. Best-effort by design: permissions decorate a capture, /// and a tree that restores with default modes is a far better outcome than one that refuses /// to restore. private static func posixPermissions(of url: URL) -> Int? { (try? FileManager.default.attributesOfItem(atPath: url.path))?[.posixPermissions] as? Int } private static func setPosixPermissions(_ permissions: Int?, of url: URL) { guard let permissions else { return } try? FileManager.default.setAttributes([.posixPermissions: permissions], ofItemAtPath: url.path) } /// Physical removal — Delete Immediately / Empty Trash (03-board-ui.md): deletes the /// folder tree from disk. Irreversible, and distinct from the ordinary delete, which is a /// *move* into `.trash/` — this call does **not** require the item to be in the trash first, /// since Delete Immediately "skips the trash from anywhere" by design. /// /// **A folder that is already gone is success, not an error** — checked first, before the /// shape guard below. A Finder deletion converges on exactly the end state a purge would /// produce (01-storage-format.md § Deletion, "a folder that disappears ... is also a /// delete"), so there is nothing left here to distinguish: a stray path that never existed /// and a once-real item someone already threw away in Finder both purge cleanly, silently, /// without inspecting what used to be there. /// /// When the folder *does* exist, `checkIsUUIDShaped` guards the same unreachability /// `moveItem`/`copyItem` rely on: a board root or a stray never purges through this /// call, only a lane or a card. public static func purgeItem(at itemFolder: URL) throws(BoardWriteError) { let operation = WriteOperation.purge(title: nil) guard FileManager.default.fileExists(atPath: itemFolder.path) else { return } try checkIsUUIDShaped(itemFolder, operation: operation) do { try FileManager.default.removeItem(at: itemFolder) } catch { throw BoardWriteError( operation: operation, path: itemFolder.path, reason: .io(message: "could not remove folder: \(error.localizedDescription)") ) } EchoLedger.current?.recordDeletion(at: itemFolder) } // MARK: - Undoing a create /// An item's `index.md` as literal text — the bytes a create hands to its own redo /// (13-native-undo.md ▸ Rules: "create → remove the created folder"). /// /// **Why the create path reads back what it just wrote.** The inverse of a create is a physical /// removal, so the only way ⇧⌘Z can put the item back *with its identity* is for the step to be /// holding the file's bytes — captured at the moment of the create, which is the write redo /// re-performs (13: the redo closure replays the original). `readRawSource(ofCard:)` is the same /// read one level narrower — it is the raw-source outlet's, and refuses anything that is not a /// card folder — so this one exists rather than widening that contract for a caller with a /// different reason. /// /// Strict UTF-8 like every read here: a file that does not decode is a loud error, never a lossy /// guess. `operation` is the create being captured for, so a failure names that gesture. public static func readIndexText( ofItem itemFolder: URL, operation: WriteOperation ) throws(BoardWriteError) -> String { let indexURL = itemFolder.appendingPathComponent(BoardLoader.indexFileName) let data: Data do { data = try Data(contentsOf: indexURL) } catch { throw BoardWriteError( operation: operation, path: indexURL.path, reason: .unreadable(message: "could not read file: \(error.localizedDescription)") ) } guard let text = String(validating: data, as: UTF8.self) else { throw BoardWriteError(operation: operation, path: indexURL.path, reason: .unreadable(message: "file is not UTF-8")) } return text } /// Puts a removed item's folder back, at its own path and with its own bytes — the redo half of /// an undone create (13-native-undo.md ▸ Rules). /// /// **The identity is the point.** `createLane`/`createCard` mint a fresh UUID and /// `materializeItem` mints a fresh one too, so neither can replay a create: redoing through them /// would produce a *different* item, and every step registered above this one on the stack /// (a rename, a move, a body edit of that very card) would then name nothing. This call takes the /// path as given. /// /// - **The parent must already exist** (`withIntermediateDirectories: false`): a redo whose lane /// has since been purged must fail rather than conjure a bare lane folder around the card. /// - **It refuses to clobber**, `createBoard`'s rule: a folder already at this path fails loudly /// rather than overwriting whatever is living there now. /// - **The bytes are written verbatim**, raw-source Apply's rule and for its reason: they are not /// composed here, they are replayed, so nothing is stamped and nothing is re-serialized. /// /// `operation` is the caller's own vocabulary word — `.createLane` or `.createCard`, whichever /// create is being replayed — so a failure banners as the gesture the user is redoing. public static func recreateItem( at itemFolder: URL, indexText: String, operation: WriteOperation ) throws(BoardWriteError) { try checkIsUUIDShaped(itemFolder, operation: operation) guard !FileManager.default.fileExists(atPath: itemFolder.path) else { throw BoardWriteError( operation: operation, path: itemFolder.path, reason: .io(message: "something already exists here") ) } do { try FileManager.default.createDirectory(at: itemFolder, withIntermediateDirectories: false) } catch { throw BoardWriteError( operation: operation, path: itemFolder.path, reason: .io(message: "could not create folder: \(error.localizedDescription)") ) } do throws(BoardWriteError) { try atomicReplace( text: indexText, at: itemFolder.appendingPathComponent(BoardLoader.indexFileName), operation: operation ) } catch { // `materializeItem`'s all-or-nothing rule: a half-made folder is pure residue, since // nothing was there before. try? FileManager.default.removeItem(at: itemFolder) throw error } } // MARK: - Task checkboxes /// Flips one task-list checkbox in an item's body — **a single-byte edit, and the only write /// in the app that touches the body at all** (05-card-window.md ▸ Preview: "clicking a /// `- [ ]` / `- [x]` checkbox flips exactly that marker in the source — a single-character /// textual edit; every other byte of the body is untouched"). /// /// It is `updateIndex`'s four steps with one addition, and the addition is why it is written /// out here rather than expressed as an `edits` closure: the flip can **refuse**, and /// `updateIndex`'s closure cannot. Everything else is identical and deliberately so — read /// fresh from disk, refuse an uneditable frontmatter shape, edit, stamp `modified` and clear /// `modified-by`, replace atomically. A toggle is "an ordinary user edit — the standard atomic /// write, auto-committed and undoable on git boards" (05), not a special case of anything. /// /// ### The offset, and why it is re-checked /// /// `bodyOffset` is a UTF-8 byte offset into the **body** (`FrontmatterDocument.body`, the text /// after the closing delimiter) naming the character *between* the brackets — the offset /// `BodyTask.markerOffset` carried out of the parse that drew the box the user clicked. /// /// That parse ran against a snapshot; this call reads disk. In between, an agent, a hand edit /// or a pull may have rewritten the file — the same staleness `updateIndex`'s read-fresh rule /// exists for, except that here the *target* is a byte offset rather than a key, and a stale /// key merely rewrites the wrong value while a stale offset would drop an `x` into the middle /// of a sentence. So `BodyMarkup.flippingTaskMarker` re-verifies the brackets, the marker, and /// the state the user saw before anything is written, and `.staleTarget` refuses when any of /// the three has moved. The refusal is loud (the banner) rather than silent: the user clicked /// a box and it did not tick. /// /// **`checked` is what the user saw, not what they want** — the flip's direction is derived /// from it, which is what makes a double-click land on one net toggle instead of racing. public static func toggleTaskMarker( inItemFolder folder: URL, bodyOffset: Int, checked: Bool ) throws(BoardWriteError) { var operation = WriteOperation.toggleTask(title: nil) try checkIsDirectory(folder, describedAs: "item folder", operation: operation) // Lanes and cards both have bodies; a board root's is its description, and no surface // previews it. The same shape guard every other item write leans on keeps this call off it. try checkIsUUIDShaped(folder, operation: operation) let indexURL = folder.appendingPathComponent(BoardLoader.indexFileName) var document = try readDocument(at: indexURL, operation: operation) operation = operation.withTitle(document.title.value) try checkEditable(document, at: indexURL, operation: operation) guard let flipped = BodyMarkup.flippingTaskMarker(in: document.body, at: bodyOffset, expecting: checked) else { throw BoardWriteError( operation: operation, path: indexURL.path, reason: .staleTarget(message: "this checkbox is no longer where it was — the file changed") ) } document.body = flipped document.set(FrontmatterKeys.modified, to: .date(Date())) document.remove(FrontmatterKeys.modifiedBy) try atomicReplace(text: document.serialized(), at: indexURL, operation: operation) } // MARK: - Card body /// Replaces an item's **body span** — everything after the frontmatter's closing delimiter — and /// leaves every frontmatter byte exactly as it was. The card window's Edit buffer landing on /// disk (05-card-window.md ▸ Edit: "Saved on a ~700 ms debounce; flushed on leaving Edit, /// entering source mode, and window close"). /// /// ### Byte-honest by the same construction as everything else here /// /// `FrontmatterDocument` keeps the file's raw text and re-emits it as /// `openingDelimiter + spans + closingDelimiter + body`, so assigning `body` is *only* a /// replacement of the body span: unknown keys, their order, comments, blank lines and line /// endings above the delimiter are the same bytes they were, because nothing re-serialized them. /// That is `toggleTaskMarker`'s idiom exactly — this call is that one widened from a single /// character to the whole span, and it shares its four steps: read fresh from disk, refuse an /// uneditable frontmatter shape, edit, stamp `modified` and clear `modified-by`, replace /// atomically. /// /// **The stamp is not optional and not a policy choice here**: a body rewrite *is* an `index.md` /// rewrite, and every app-mediated `index.md` rewrite stamps (01-storage-format.md § /// Frontmatter). The raw-source Apply is the one path that keeps a `modified-by`, and it does /// not come through here. /// /// ### The gate, and why it lives in the Writer as well as in the session /// /// **An untouched body is never re-serialized** (05 ▸ Write rules): if the text on disk already /// equals `body`, this returns `false` having opened the file and touched nothing — no stamp, no /// temp file, no rename, and therefore an untouched `mtime`. The card window's Edit session /// gates on the same comparison before it ever calls (its three gates: untouched, reverted, and /// the echo of an external edit), so in practice this one never fires; it is here because the /// guarantee is about *bytes on disk*, and the layer that owns the bytes is the layer that can /// promise it against every caller, including a future one. /// /// **It is not a staleness check.** A body that changed under the buffer is written over /// deliberately — "dirty buffer wins ... deliberate last-writer-wins" (05 ▸ Write rules) — which /// is why nothing here compares against what the caller last saw. Only *equality* refuses, and /// equality refuses because the write would be a no-op that stamped `modified` anyway. /// /// - Returns: `true` when bytes were written, `false` when the body on disk already matched. @discardableResult public static func writeBody(inItemFolder folder: URL, body: String) throws(BoardWriteError) -> Bool { var operation = WriteOperation.editBody(title: nil) try checkIsDirectory(folder, describedAs: "item folder", operation: operation) // The same shape guard `toggleTaskMarker` leans on, for its reason: a board root's body is // its description and no editor in this app opens it, so only lanes and cards are reachable. try checkIsUUIDShaped(folder, operation: operation) let indexURL = folder.appendingPathComponent(BoardLoader.indexFileName) var document = try readDocument(at: indexURL, operation: operation) operation = operation.withTitle(document.title.value) try checkEditable(document, at: indexURL, operation: operation) guard document.body != body else { return false } document.body = body document.set(FrontmatterKeys.modified, to: .date(Date())) document.remove(FrontmatterKeys.modifiedBy) try atomicReplace(text: document.serialized(), at: indexURL, operation: operation) return true } // MARK: - Raw source /// Reads a card's `index.md` as **literal text** — what the raw-source outlet opens /// (05-card-window.md ▸ Raw source outlet: "swaps the entire content area … for the literal /// on-disk `index.md` (frontmatter and all)"; "Entering source mode flushes any pending title/body /// edits first, then reads the file fresh from disk"). /// /// **Fresh from disk, never from a snapshot** — the same rule `updateIndex` opens with, and here /// it is the feature rather than a precaution: the user asked to see the file, and a stale /// in-memory rendering of it is the one thing this surface must never show. /// /// **It does not parse.** Every other read in this file parses because it is about to edit a /// document; this one hands the bytes to a text editor. A file whose frontmatter an agent has /// just broken is exactly what the outlet exists to let a human fix, and refusing to *open* it /// would close the only door to the repair. Apply validates on the way back out. /// /// The one refusal is **encoding**: bytes that are not UTF-8 cannot be shown as text without /// inventing characters, and applying that invention would rewrite the file into a transcoding /// the user never asked for. Strict `String(validating:as:)`, so a BOM survives into the buffer /// visibly rather than being silently swallowed and silently re-added (01-storage-format.md § /// Fractal layout ▸ Rules). public static func readRawSource(ofCard cardFolder: URL) throws(BoardWriteError) -> String { let operation = WriteOperation.rawSource(title: nil) try checkIsDirectory(cardFolder, describedAs: "card folder", operation: operation) // A card's `index.md` and only a card's: the outlet is the card window's, and a lane or a // board root reached through it would put a surface with no window behind it on disk. try checkIsCardFolder(cardFolder, operation: operation) let indexURL = cardFolder.appendingPathComponent(BoardLoader.indexFileName) let data: Data do { data = try Data(contentsOf: indexURL) } catch { throw BoardWriteError( operation: operation, path: indexURL.path, reason: .unreadable(message: "could not read file: \(error.localizedDescription)") ) } guard let text = String(validating: data, as: UTF8.self) else { throw BoardWriteError(operation: operation, path: indexURL.path, reason: .unreadable(message: "file is not UTF-8")) } return text } /// Writes a card's `index.md` **byte-for-byte from the user's text** — the raw-source Apply /// (05-card-window.md ▸ Raw source outlet), and the one write in this app that is not a document /// edit at all. /// /// ### Validated, then verbatim /// /// 1. **Validate through the loader's own fail-fast parse** (`BoardLoader.validateCardIndex`): /// decode, parse, `schema`, `order`. The card window has already run this to raise its alert; /// it runs again here for `writeBody`'s reason — the layer that owns the bytes is the layer /// that can promise the app never writes a file its own loader would refuse to load, against /// every caller including a future one. /// 2. **Write the user's text exactly.** `atomicReplace` emits `Data(text.utf8)`, so what lands is /// the buffer's own bytes: unknown keys, comments, key order, blank lines, line endings and a /// missing final newline all survive because nothing re-serialized them — not because anything /// here remembered to preserve them. /// /// ### No stamps. Deliberately, and stated twice in the design /// /// This path does **not** set `modified` and does **not** clear `modified-by`, and it is the only /// write in the app of which both are true. 01-storage-format.md § Frontmatter settles each /// explicitly: `modified` "updates on every app write that rewrites the item's `index.md`, and /// only those … Two designed app writes therefore don't bump it, deliberately: **raw-source /// Apply** writes the validated buffer byte-for-byte (the verbatim contract outranks stamping)"; /// and `modified-by` gets "One carve-out: **raw-source Apply** … writes byte-for-byte and does /// *not* clear a stamp the user typed or kept". 05 says the same from the other side ("including /// a `modified-by` stamp the user typed or kept — Apply is the one app write that doesn't clear /// it"). /// /// The reasoning is worth keeping next to the code: every other write here is *composed* by the /// app — the user asked for a rename, a move, a body edit, and the app decided which bytes express /// it, so stamping is the app reporting its own authorship. Here the user typed the bytes. A stamp /// would be the app editing a file it was told to write literally, and "byte-for-byte" would be /// false in the one place the whole feature rests on it. /// /// ### The equality gate /// /// Text identical to what is already on disk writes nothing and returns `false` — `writeBody`'s /// untouched gate, applied to the whole file instead of the body span. Apply on a buffer the user /// only read must not churn `mtime`, wake every watcher, and (on git boards) mint an empty commit. /// /// - Returns: `true` when bytes were written, `false` when the file already read exactly like /// `text`. @discardableResult public static func writeRawSource(inCard cardFolder: URL, text: String) throws(BoardWriteError) -> Bool { var operation = WriteOperation.rawSource(title: nil) try checkIsDirectory(cardFolder, describedAs: "card folder", operation: operation) try checkIsCardFolder(cardFolder, operation: operation) let indexURL = cardFolder.appendingPathComponent(BoardLoader.indexFileName) // Best-effort, and both of its uses tolerate failure: the equality gate treats "cannot read" // as "not equal" (write it), and the title enrichment falls back to the untitled phrasing. let current = try? Data(contentsOf: indexURL) if let current, let document = try? BoardLoader.parseDocument(current, path: BoardLoader.indexFileName) { // The title as the file *currently* reads, not as the buffer proposes it — `.rename`'s // rule: a failed write names the card the user is still looking at in the title bar, // rather than a name that never landed. operation = operation.withTitle(document.title.value) } let proposed = Data(text.utf8) do throws(BoardLoadError) { _ = try BoardLoader.validateCardIndex(proposed, path: BoardLoader.indexFileName) } catch { throw BoardWriteError(operation: operation, path: indexURL.path, reason: .invalidSource(error)) } guard current != proposed else { return false } try atomicReplace(text: text, at: indexURL, operation: operation) return true } // MARK: - Attachments /// 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. 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 /// already taken is auto-renamed Finder-style (`shot.png` → `shot 2.png` → `shot 3.png`, …) /// rather than overwritten or bounced. /// /// A multi-file drop imports **in order, one finished copy at a time** — not a transaction /// across the batch: the first failure stops it and throws naming the failing source file, /// but every file already imported stays landed, because each import already completed as /// its own write before the next one starts. Returns the landed names, in input order, for /// exactly the files that made it in. /// /// The order of checks is the contract: /// /// 1. **`cardFolder` must be an existing, UUID-shaped directory** — attachments belong to /// cards, the same shape guard `moveItem`/`copyItem`/`purgeItem` lean on /// (`checkIsUUIDShaped`): a lane or a board root is refused before anything else happens. /// 2. **`attachments/` is created if missing** (`.io` naming `cardFolder` on failure) — the /// one exception to "subfolders are never created by the app" (§ Attachments); every /// other folder under it is the user's or a hand-editor's, left alone. /// 3. **Each source is validated before it is touched**: must exist and be a regular file — /// not a directory, which is out of this call's scope entirely — refused `.unreadable` /// naming the source *before* any copy for that file is attempted, so a batch never /// partially copies something it was about to refuse. /// 4. **The collision-free name is decided, then copied to directly** — no temp name and /// rename, unlike `atomicReplace`: `FileManager.copyItem` lands the bytes straight under /// the final name. `copyItem` is not atomic, so on any failure the partial destination is /// removed best-effort and `.io` is thrown naming the source file — "no half-copied /// attachment is ever left in `attachments/`" (02-architecture.md § Write-failure /// surfacing) is a promise about the *failure path*, not a stronger atomicity claim /// `copyItem` cannot make. A crash mid-copy (process death, not a caught `Error`) can /// still leave a partial file on disk — the accepted limit of a non-atomic copy, the same /// one an ordinary Finder copy has. public static func importAttachments( _ sourceURLs: [URL], intoCard cardFolder: URL ) throws(BoardWriteError) -> [ImportedAttachment] { // Before the per-file loop starts, no single source is implicated yet — the first // source's own (original, pre-collision-rename) name stands in for the batch; an empty // `sourceURLs` (nothing was actually dropped) falls back to the empty string rather than // crashing on `first!`. let batchOperation = WriteOperation.importAttachment(filename: sourceURLs.first?.lastPathComponent ?? "") try checkIsDirectory(cardFolder, describedAs: "card folder", operation: batchOperation) try checkIsUUIDShaped(cardFolder, operation: batchOperation) let attachmentsFolder = cardFolder.appendingPathComponent(attachmentsFolderName, isDirectory: true) do { try FileManager.default.createDirectory(at: attachmentsFolder, withIntermediateDirectories: true) } catch { throw BoardWriteError( operation: batchOperation, path: cardFolder.path, reason: .io(message: "could not create attachments folder: \(error.localizedDescription)") ) } var landed: [ImportedAttachment] = [] for sourceURL in sourceURLs { // Each source names itself — the ORIGINAL filename, not the Finder-style renamed one // decided a few lines down, because the operation describes what the user dropped. let operation = WriteOperation.importAttachment(filename: sourceURL.lastPathComponent) var isDirectory: ObjCBool = false guard FileManager.default.fileExists(atPath: sourceURL.path, isDirectory: &isDirectory) else { throw BoardWriteError( operation: operation, path: sourceURL.path, reason: .unreadable(message: "file does not exist") ) } guard !isDirectory.boolValue else { throw BoardWriteError( operation: operation, path: sourceURL.path, reason: .unreadable(message: "is a folder, not a file — importing a folder is not supported") ) } let name = freshAttachmentName(for: sourceURL.lastPathComponent, in: attachmentsFolder) let destinationURL = attachmentsFolder.appendingPathComponent(name) do { try FileManager.default.copyItem(at: sourceURL, to: destinationURL) // "Attachment imports hash during the copy" — see `EchoLedger.recordImport(at:)` // for the one place that phrase and `FileManager.copyItem` do not quite meet. EchoLedger.current?.recordImport(at: destinationURL) } catch { try? FileManager.default.removeItem(at: destinationURL) throw BoardWriteError( operation: operation, path: sourceURL.path, reason: .io(message: "could not copy file: \(error.localizedDescription)") ) } landed.append(ImportedAttachment(sourceURL: sourceURL, fileName: name)) } return landed } /// Moves loose files out of a card folder and into its `attachments/` — the **write half** of /// 01-storage-format.md's loose-file carve-out (§ Fractal layout ▸ Rules, settled 2026-07-28, /// "Lanework-owns-the-board"): "a regular file sitting beside a card's `index.md` … belongs in /// `attachments/`, and the app moves it there — Finder-style rename on collision". /// /// **A move, not a copy** — the file is not being imported from somewhere else, it is being put /// where it already belonged, and leaving a second copy beside `index.md` would leave the very /// thing this call exists to clear. `FileManager.moveItem` within one folder is a `rename(2)`: /// atomic, and byte-preserving by not touching bytes at all. /// /// **`index.md` is never opened.** Relocating a stray says nothing about the card's content, so /// no `modified` stamp is written and no frontmatter is read — which is also why a card whose /// frontmatter is uneditable (a flow mapping) still gets its files tidied. /// /// `names` is the caller's list — the loader's `looseFileNames` at the store, or this file's own /// `normalizeLooseFiles(inCard:)` at the import boundary — and **every name is re-checked /// against disk before it is touched** (`isRelocatable`). A name that has stopped being a plain /// non-hidden regular file since it was listed, or that names a reserved child, or that is not a /// bare filename at all, is **skipped silently**: the reload is the authority on what is there, /// and a file the user deleted between the walk and the write is not a failure to report. That /// re-check is also what makes the rule "folders and symlinks are never relocated" a property of /// this call rather than of its callers. /// /// The batch is `importAttachments`' shape exactly: in order, one finished move at a time, the /// first failure stopping it and throwing while everything already moved stays moved. Returns /// what actually landed, in input order — `sourceURL` naming the file where it sat, `fileName` /// the (possibly Finder-renamed) name it took inside `attachments/`. /// /// `attachments/` is created only when something is actually going to move into it, so a card /// whose loose files all vanished under the write is left exactly as it was — no empty folder /// minted for nothing. /// /// **`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, healMarked: Bool = true ) throws(BoardWriteError) -> [ImportedAttachment] { guard !names.isEmpty else { return [] } // Before the per-file loop starts no single file is implicated yet — the first name stands // in for the batch, exactly as `importAttachments` lets its first source name it. let batchOperation = WriteOperation.relocateLooseFile(filename: names[0]) try checkIsDirectory(cardFolder, describedAs: "card folder", operation: batchOperation) try checkIsCardFolder(cardFolder, operation: batchOperation) let relocatable = names.filter { isRelocatable($0, in: cardFolder) } guard !relocatable.isEmpty else { return [] } let attachmentsFolder = cardFolder.appendingPathComponent(attachmentsFolderName, isDirectory: true) do { try FileManager.default.createDirectory(at: attachmentsFolder, withIntermediateDirectories: true) } catch { throw BoardWriteError( operation: batchOperation, path: cardFolder.path, reason: .io(message: "could not create attachments folder: \(error.localizedDescription)") ) } var moved: [ImportedAttachment] = [] for name in relocatable { // Each file names itself — the ORIGINAL name, not the Finder-style renamed one decided // on the next line, for `importAttachments`' reason: the operation describes the file // the user (or their agent) actually wrote. let operation = WriteOperation.relocateLooseFile(filename: name) let sourceURL = cardFolder.appendingPathComponent(name) let landed = freshAttachmentName(for: name, in: attachmentsFolder) let landedURL = attachmentsFolder.appendingPathComponent(landed) do { 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, path: sourceURL.path, reason: .io(message: "could not move file into attachments: \(error.localizedDescription)") ) } moved.append(ImportedAttachment(sourceURL: sourceURL, fileName: landed)) } return moved } /// Discovers *and* relocates — the loose-file rule applied at an **import boundary**, where /// there is no loader round trip to discover through (04-interactions.md ▸ Clipboard, settled /// 2026-07-28: "A paste is an import boundary, so normalization applies … loose files the staged /// snapshot carries beside a card's `index.md` land in the pasted card's `attachments/`, /// Finder-renamed on collision — nothing the snapshot preserved is dropped on arrival"). /// /// The pasted card therefore lands **already normalized**, rather than arriving loose and being /// tidied a reload later: the write is happening anyway, and one that leaves work behind for the /// carve-out to find would also post the carve-out's notice — a warning row about a mess the /// user's own paste made and the app immediately cleaned up. /// /// 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] { // `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 /// lane, in folder order. /// /// Children are `childCandidates` — the loader's own level detection — so a stray *folder* /// inside the arriving lane is neither descended into nor tidied, and nothing below a card is /// reached: the carve-out is card-level and one level deep, exactly as 01 states it. @discardableResult public static func normalizeLooseFiles(inLane laneFolder: URL) throws(BoardWriteError) -> [ImportedAttachment] { var moved: [ImportedAttachment] = [] for card in childCandidates(of: laneFolder) { moved.append(contentsOf: try normalizeLooseFiles(inCard: card)) } return moved } /// Refuses any folder that is not a **card**: UUID-shaped, *under* a UUID-shaped parent. /// /// `checkIsUUIDShaped` is the guard every other item write leans on, and it is the wrong one /// here because it cannot tell a lane from a card — both are UUID-shaped, which is exactly the /// distinction the carve-out turns on ("everything at board or lane level keeps the verbatim /// posture"; "board/lane-level strays … are legitimate residents"). Pointing the relocation at a /// lane would sweep a hand-editor's `notes.txt` into an `attachments/` folder no lane should /// ever have. /// /// The parent test is exact rather than heuristic because 01-storage-format.md § Fractal layout /// fixes the depth: a card is `//` and a lane is `/`, so a /// UUID-shaped folder whose parent is *also* UUID-shaped is a card and nothing else. It is the /// same reading `BoardStore.boardRoot(ofCardFolder:)` already derives a root from. private static func checkIsCardFolder(_ folder: URL, operation: WriteOperation) throws(BoardWriteError) { try checkIsUUIDShaped(folder, operation: operation) guard BoardLoader.isUUIDShaped(folder.deletingLastPathComponent().lastPathComponent) else { throw BoardWriteError( operation: operation, path: folder.path, reason: .unreadable(message: "folder is not a card: only a card's own files are relocated") ) } } /// Whether `name` inside `cardFolder` is a file this app may relocate: a bare filename (never a /// path), not hidden, not one of the reserved card-level names, and — read from disk, at write /// time — a regular file that is not a symlink. /// /// The four name rules restate the loader's listing exclusions rather than trusting them, /// because `relocateLooseFiles` takes a caller's list: this is where "the carve-out is exactly /// that narrow" stops being a convention and becomes something the filesystem-touching code /// enforces on its own. private static func isRelocatable(_ name: String, in cardFolder: URL) -> Bool { guard !name.isEmpty, !name.hasPrefix("."), !name.contains("/"), !BoardLoader.reservedCardChildNames.contains(name.lowercased()), let values = try? cardFolder .appendingPathComponent(name) .resourceValues(forKeys: [.isRegularFileKey, .isSymbolicLinkKey]) else { return false } return values.isRegularFile == true && values.isSymbolicLink != true } /// The Finder-style collision-free name for `originalName` landing in `folder`: the name /// itself when nothing on disk claims it yet, else the base name suffixed `" 2"`, `" 3"`, … /// — counting up from 2 against what is on disk *at decision time*, one collision at a time. /// /// Splits `originalName` the way `URL` itself does (`deletingPathExtension`/ /// `pathExtension`), which is also exactly how Finder does it: an extension-less name /// suffixes directly (`"notes"` → `"notes 2"`), and a multi-dot name splits after the /// *last* dot (`"archive.tar.gz"` → `"archive.tar 2.gz"`, not `"archive 2.tar.gz"`) — both /// accepted as what Finder itself produces, not worked around. /// /// `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 { 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 } let nameURL = URL(fileURLWithPath: originalName) let base = nameURL.deletingPathExtension().lastPathComponent let ext = nameURL.pathExtension var counter = 2 while true { let candidate = ext.isEmpty ? "\(base) \(counter)" : "\(base) \(counter).\(ext)" 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 } /// **Remints a withheld duplicate identity** — the write half of the duplicate-id heal /// (01-storage-format.md § Fractal layout ▸ Rules, re-ruled 2026-07-29: "the heal gives each /// withheld occurrence the fresh identity the import boundary would have minted"). /// /// Copy semantics applied at detection: a hand copy in Finder *was* a copy, so it gets what a /// copy gets — a fresh lowercase v4 folder name, minted away from every identity in the board. /// After it lands, the folder renders as an ordinary item. /// /// **A rename and nothing else.** The folder keeps its parent; its `index.md`, its frontmatter, /// its children, its attachments and its strays are never opened. That is not a carve-out but the /// existing write discipline answering: a heal that only renames stamps nothing — no `modified`, /// no cleared `modified-by` — because this is an identity repair, not an edit (§ Validation and /// healing). /// /// **It re-verifies against disk, twice over** (§ Validation and healing: "every scheduled heal /// re-verifies its defect against disk at write time and no-ops when it is gone"), and each check /// answers `nil` — success, never an error: /// /// 1. The folder is still there, still a directory, and still spelled with the identity the /// detection named. A folder already reminted (this heal running twice, another device's heal /// arriving first) fails here. /// 2. **Something else still carries that identity.** The winner may have been hand-deleted or /// moved out since the load, in which case this folder is no longer a duplicate of anything and /// reminting it would change an identity for no reason at all — the vanished-duplicate race, /// read from the surviving side. /// /// **Heal-marked**, because the app started it on its own: the receipt is what splits the remint /// into its own commit on git boards, named for the Repair verb (06-history-undo.md ▸ Commit /// messages). Undo never sees it — heals are not gestures (13-native-undo.md). /// /// - Parameter duplicate: the withheld occurrence, `path` relative to `boardRoot` so the write /// lands wherever the board lives *now*. /// - Returns: the fresh identity, or `nil` when the defect was already gone. @discardableResult public static func remintDuplicateIdentity( _ duplicate: DuplicateIdentity, inBoard boardRoot: URL ) throws(BoardWriteError) -> ItemID? { let operation = WriteOperation.repairDuplicateID(title: duplicate.title) let folder = boardRoot.appendingPathComponent(duplicate.path, isDirectory: true) // 1. Still there, still a folder, still carrying the identity that lost. guard IntegrityRules.node(at: folder) == .directory, IntegrityRules.canonicalIdentity(folder.lastPathComponent) == duplicate.identity else { return nil } // 2. Still a duplicate *of something*. One occurrence is this folder itself, so the identity // has to appear at least twice for the defect to still stand. let occurrences = identityOccurrences(inBoard: boardRoot) guard occurrences.filter({ $0 == duplicate.identity }).count > 1 else { return nil } // Minted away from every identity in the board, not merely from this parent's children: the // point of the remint is board-wide uniqueness, and a fresh name colliding with a folder two // lanes over would trade one duplicate for another. let fresh = freshUUIDName(in: folder.deletingLastPathComponent(), avoiding: Set(occurrences)) try renameFolder(folder, toSiblingNamed: fresh, operation: operation) // The move pair is `renameFolder`'s; the heal mark is this call's, because the remint is // app-initiated work whose paths commit separately (06-history-undo.md ▸ Commit messages). EchoLedger.current?.markHeal( at: folder.deletingLastPathComponent().appendingPathComponent(fresh, isDirectory: true) ) return ItemID(rawValue: fresh) } /// 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 /// `localizedStandardCompare` — Finder order, so `"shot 2.png"` sorts before `"shot /// 10.png"` rather than after it. /// /// A **missing** `attachments/` lists as `[]`, not an error: nothing has been imported yet /// is an ordinary state, not a malformed one. `cardFolder` itself missing, or not a /// directory, *is* `.unreadable` — that is a caller asking about a card that isn't there, /// not an empty listing. Purely a read: nothing here ever creates `attachments/` or /// disturbs anything inside it, subfolders included. /// /// The listing itself is `BoardLoader.attachmentNames(in:)`, which is also what fills /// `Card.attachments` for the board window's face indicator. **One function, so the sidebar /// and the face can never disagree** about a card's attachments or their order. /// What this call adds over that one is the card-folder guard and this API's error /// vocabulary — the difference between a surface that *acts* on the files and two that /// merely show them. public static func listAttachments(ofCard cardFolder: URL) throws(BoardWriteError) -> [String] { let operation = WriteOperation.listAttachments try checkIsDirectory(cardFolder, describedAs: "card folder", operation: operation) return BoardLoader.attachmentNames(in: cardFolder) } /// Moves one of a card's attachments to the **system** Trash — the attachment row's Remove and /// its ⌫ twin (05-card-window.md ▸ Attachments: "context menu Open / Reveal in Finder / Remove /// (moves to the **system** Trash, never hard-deletes …)"). /// /// ### `trashItem`, and never `removeItem` /// /// The design says *system* Trash and means it: a board's own tombstone quasi-lane is a different /// trash with a different vocabulary (03-board-ui.md § Trash's naming constraint), and an /// attachment removed here is recoverable exactly the way a file dragged out of a Finder window /// is — by the user, in Finder, with no help from this app. `FileManager.trashItem` is that /// sentence; `removeItem` would be a hard delete of the user's own file, which this app never /// does to an attachment. /// /// ### The listing is the guard /// /// `name` is checked against `BoardLoader.attachmentNames(in:)` — **the very set the sidebar /// shows** — before anything is touched, which is what makes the whole class of "the caller /// passed something else" unreachable in one line rather than four: a path (`../index.md`), a /// subfolder, a hidden file, a symlink, and the empty string are all simply not in the listing, /// and none of them can be trashed through this call. It is also `relocateLooseFiles`' re-check /// rule applied here — the caller's name is re-read against disk at write time, not trusted from /// whenever the row was drawn. /// /// ### A name that is no longer there is not a failure /// /// It returns `false` and writes nothing, `relocateLooseFiles`' rule again: "the reload is the /// authority on what is there, and a file the user deleted between the walk and the write is not /// a failure to report". A ⌫ racing an external delete of the same file is exactly that race, and /// a banner for it would name a removal the user got anyway. /// /// - Returns: where the file now sits **inside the Trash**, or `nil` when there was nothing to /// move. The URL is returned rather than discarded because it is the only proof this call /// makes that the file still exists at all — the difference between the promise ("never /// hard-deletes") and a `removeItem` that would look identical from `attachments/`. @discardableResult public static func removeAttachment( named name: String, fromCard cardFolder: URL ) throws(BoardWriteError) -> URL? { let operation = WriteOperation.removeAttachment(filename: name) try checkIsDirectory(cardFolder, describedAs: "card folder", operation: operation) try checkIsUUIDShaped(cardFolder, operation: operation) guard BoardLoader.attachmentNames(in: cardFolder).contains(name) else { return nil } let fileURL = cardFolder .appendingPathComponent(attachmentsFolderName, isDirectory: true) .appendingPathComponent(name) var trashedURL: NSURL? do { try FileManager.default.trashItem(at: fileURL, resultingItemURL: &trashedURL) // Where the file went is the *system* Trash, outside any board — so from this board's // point of view it is an absence, exactly as a purge is. EchoLedger.current?.recordDeletion(at: fileURL) } catch { throw BoardWriteError( operation: operation, path: fileURL.path, reason: .io(message: "could not move file to the Trash: \(error.localizedDescription)") ) } return trashedURL as URL? } // MARK: - Move/copy pre-flight /// The rank a moved or copied root lands on: the caller's explicit value — a drop between /// two siblings, whose midpoint only the caller knows — or `max + 1024` over the /// destination's visible siblings when it has none (`Ranks.append(toVisible:)`, tombstones /// inert). Computed *before* the item arrives, so it cannot count itself as its own sibling. /// /// `requireEditable: false` for the same reason the creates pass it: this only *reads* the /// siblings' orders. A flow-mapping sibling that loads and renders normally must not block /// dropping an item beside it. private static func destinationOrder( _ order: Double?, inParent parentFolder: URL, operation: WriteOperation ) throws(BoardWriteError) -> Double { if let order { return order } let siblings = try visibleSiblings(of: parentFolder, operation: operation, requireEditable: false) return Ranks.append(toVisible: siblings.map(\.order)) } /// Refuses a folder that is not a lane or a card. Level detection is by name shape /// (01-storage-format.md § Fractal layout ▸ Rules), so a stray — `notes/`, a truncated or /// non-hex UUID, a hand-made folder — is not an item, and moving, copying, deleting, restoring, or /// purging one as if it were would invent (or destroy) an identity the loader would /// otherwise just ignore. Shared by every operation that must never reach a board root: a /// board root's folder name is never UUID-shaped (§ Board naming), so this one check is /// what makes board-root deletion/restore/purge structurally unreachable at this layer. private static func checkIsUUIDShaped(_ folder: URL, operation: WriteOperation) throws(BoardWriteError) { guard BoardLoader.isUUIDShaped(folder.lastPathComponent) else { throw BoardWriteError( operation: operation, path: folder.path, reason: .unreadable(message: "folder name is not UUID-shaped: only lanes and cards are valid here") ) } } /// The discover-before-you-write pre-flight `moveItem` and `copyItem` both run on the root /// they are about to relocate: that file *will* be rewritten at the destination (its `order`, /// plus the stamps), so one that cannot be read or cannot be edited in place refuses the /// whole gesture while nothing has happened yet — rather than after the folder has already /// travelled, or with a copy already materialized. /// /// Returns `operation` enriched with the title this same read just learned /// (`WriteOperation.withTitle`) — the caller's *only* read of the moved/copied root before it /// travels, so this is the one place `moveItem`/`copyItem` can learn it at all. Returned /// rather than discarded so every failure after the pre-flight passes (the `FileManager` /// move/copy itself, the post-arrival `updateIndex`) also names the item. private static func checkIndexIsRewritable( inItemFolder folder: URL, operation: WriteOperation ) throws(BoardWriteError) -> WriteOperation { let indexURL = folder.appendingPathComponent(BoardLoader.indexFileName) let document = try readDocument(at: indexURL, operation: operation) let operation = operation.withTitle(document.title.value) try checkEditable(document, at: indexURL, operation: operation) return operation } // MARK: - Reading /// Reads and parses an `index.md` for rewriting. **Strict, byte-faithful UTF-8**: /// `String(validating:as:)` rejects malformed sequences outright and — unlike Foundation's /// NSString-backed decoders — does not silently swallow a leading BOM, which would turn a /// rewrite of a BOM'd file into a whole-file byte change. A file that does not decode, or /// whose frontmatter does not parse, is `.unreadable` with the specifics: the app declines /// to write a file it cannot round-trip (01-storage-format.md § Fractal layout ▸ Rules). private static func readDocument(at url: URL, operation: WriteOperation) throws(BoardWriteError) -> FrontmatterDocument { let data: Data do { data = try Data(contentsOf: url) } catch { throw BoardWriteError( operation: operation, path: url.path, reason: .unreadable(message: "could not read file: \(error.localizedDescription)") ) } guard let text = String(validating: data, as: UTF8.self) else { throw BoardWriteError(operation: operation, path: url.path, reason: .unreadable(message: "file is not UTF-8")) } do { return try FrontmatterDocument.parse(text) } catch { throw BoardWriteError(operation: operation, path: url.path, reason: .unreadable(message: error.description)) } } private static func checkEditable( _ document: FrontmatterDocument, at url: URL, operation: WriteOperation ) throws(BoardWriteError) { if let shape = document.uneditableShape { throw BoardWriteError(operation: operation, path: url.path, reason: .uneditableFrontmatter(shape)) } } } // MARK: - Move and copy vocabulary /// What a move did to identity: where the item ended up, plus every remint the import boundary /// performed on the way in (01-storage-format.md § Fractal layout ▸ Rules, "Identity lifecycle"). /// /// `id` is the source identity unchanged for every ordinary move — same board or not — and the /// minted one when the arriving root itself collided. `reminted` is empty for every move that /// crossed no import boundary, and for every import that found no collision; when it is not, /// each entry is a folder whose UUID the destination board already held, repaired in place. /// Callers need both: `id` to select or scroll to the moved item afterwards, `reminted` to /// re-point anything that was holding the old identities (a selection, an open card window). /// /// Content is not part of this: a remint renames a folder and touches no bytes, so nothing in /// here implies an edit. public struct MoveResult: Sendable, Equatable { public let id: ItemID public let reminted: [Remint] public struct Remint: Sendable, Equatable { public let from: ItemID public let to: ItemID } } // MARK: - Subtree vocabulary /// A folder tree captured whole, in memory — what `BoardWriter.captureSubtree(at:operation:)` /// produces and `recreateSubtree(at:from:operation:)` replays. /// /// **A value, deliberately**: `Sendable` so an undo step can carry it across isolation domains, /// and `Equatable` so a capture → recreate → capture round trip is one assertion. Equality is /// exact — names, bytes, link destinations, permissions, and order — which holds because a capture /// sorts every level by name. /// /// It describes bytes, never meaning. There is no `index.md` here, no frontmatter, no identity: /// a lane, a card, an `attachments/` folder and a hand-made `notes/` are all just folders with /// entries, which is exactly what a byte-faithful restore needs and all it may assume. public struct SubtreeSnapshot: Sendable, Equatable { /// The captured folder's own name. Carried for identification and for nested folders' paths; /// the top-level replay takes its path from the caller instead (see `recreateSubtree`). public let name: String /// POSIX permission bits as captured, `nil` when unreadable — applied best-effort on replay. public let permissions: Int? /// The folder's direct children, sorted by name. public let entries: [Entry] public init(name: String, permissions: Int?, entries: [Entry]) { self.name = name self.permissions = permissions self.entries = entries } /// One captured child. Symlinks are their own case rather than a file holding their target's /// bytes — "symlinks are never traversed" (01-storage-format.md § Fractal layout ▸ Rules), so /// a capture records the link and a replay recreates the link. public enum Entry: Sendable, Equatable { case file(name: String, contents: Data, permissions: Int?) case folder(SubtreeSnapshot) case symlink(name: String, destination: String) } } /// How a copy stamps the files it materializes — the one axis on which the two kinds of copy /// differ (01-storage-format.md § Fractal layout ▸ Rules; § Frontmatter). public enum CopyStamps: Sendable { /// An ordinary copy — paste, duplicate, a cross-board drag: **keeps `created`**, because a /// fork really was created when its original was, stamps `modified`, and clears /// `modified-by` (a copy is an app write). case fork /// Template instantiation (09-templates.md): stamps `created` **and** `modified` fresh and /// strips `modified-by` — the result is born today, not forked from the template. case born } // MARK: - Attachment vocabulary /// One imported attachment: where it came from and the name it landed under. `fileName` is the /// post-collision-rename name — `sourceURL.lastPathComponent` when nothing on disk claimed it, /// the Finder-style `"… 2"` (or higher) variant otherwise (`importAttachments`). `sourceURL` is /// carried through unchanged so a caller can report the batch (which files came from where) /// without re-deriving it from input order. public struct ImportedAttachment: Sendable, Equatable { public let sourceURL: URL public let fileName: String } // MARK: - Write operation vocabulary /// What the Writer was doing when it failed — a closed vocabulary, not a string (settled, /// 02-architecture.md § Write-failure surfacing). The banner layer switches exhaustively over /// this to phrase user-facing text, so a new operation here is a compile-time hole there, never /// a silent default. `title` is the affected item's title where the operation learned it before /// failing (nil when the failure struck before the title could be read). public enum WriteOperation: Sendable, Equatable, CustomStringConvertible { case createBoard case createLane case createCard case move(title: String?) case reorder(title: String?) case copy(title: String?) /// ⌫ / ⌘⌫ — a card moving into `.trash/` (`deleteCardToTrash`) or a lane being removed /// outright (`removeLane`). The word the user pressed, whichever staging it took. /// /// **There is no `restore` case**: restoring is an ordinary move out (`moveItem`), so a failed /// restore says the app couldn't *move* the card — which is exactly what it couldn't do /// (03-board-ui.md § Trash, resettled 2026-07-28 — Put Back is retired with the tombstone model). case delete(title: String?) case purge(title: String?) /// A legacy `deleted:` key being migrated away — a card relocating into `.trash/` with the key /// removed, or a lane getting the key stripped and returning live (01-storage-format.md /// § Deletion, "Legacy `deleted:` keys migrate on load-and-write, never destroy"). /// /// Its own case rather than a fold into `.delete` or `.move`, on the vocabulary's standing /// reasoning and `.relocateLooseFile`'s in particular: this is work the *app* started on its /// own, on a board an older version wrote, and a banner telling the user the app "couldn't /// delete 'Fix login'" would name a gesture they never made — on a lane, one whose outcome is /// the opposite of deletion. case migrateTombstone(title: String?) case style(title: String?) // updateIndex on behalf of styling flows (03-board-ui.md) case resize(title: String?) // a lane's `width` — the edge drag and the stepper alike (03-board-ui.md § Lane) /// An inline title editor's commit — the third inline editor's write (04-interactions.md ▸ /// Grammar). Its own case rather than a fold into `.style`: "the vocabulary grows with the /// surfaces" is settled (02-architecture.md § Write-failure surfacing, which names /// "Couldn't rename 'Fix login'…" verbatim), and a rename that failed must not tell the user /// the app could not *restyle* something. `title` is the item's title as it stood **before** /// the edit — `updateIndex` enriches it off the document it just read — which is the name the /// user is still looking at when the banner appears. case rename(title: String?) /// File ▸ Duplicate — the whole-board copy (03-board-ui.md § Welcome screen & templates). Its /// own case rather than a fold into `.copy`, on `.rename`'s reasoning: the user pressed /// *Duplicate*, and a banner telling them the app could not "copy the item" would name neither /// the command nor the thing. `title` is the board's display name. case duplicateBoard(title: String?) /// File ▸ Save as Template — the whole-board copy into the user templates store, and the /// `template:` key stamped on the copy (09-templates.md ▸ Save as Template). Its own case beside /// `.duplicateBoard`, on that case's own reasoning: both copy a board wholesale, but the user /// pressed a different command with a different outcome, and a banner saying the app could not /// "duplicate" a board when no duplicate was asked for would name a gesture that never happened. /// `title` is the board's display name. case saveAsTemplate(title: String?) case importAttachment(filename: String) case listAttachments /// An attachment being moved to the **system** Trash — the card window's attachment Remove and /// its ⌫ twin (05-card-window.md ▸ Attachments). /// /// Its own case rather than a fold into `.delete`, on the vocabulary's standing reasoning and /// then some: `.delete` is the board's *tombstone*, and this app has two trashes on purpose — /// "Finder's 'Move to Trash' phrasing is reserved for the system Trash; board deletion says /// 'Delete'" (03-board-ui.md § Trash). A banner saying the app "couldn't delete 'shot.png'" /// would claim the board's own trash took a hand in a file only Finder can give back. /// `filename` is the attachment's name as the sidebar shows it. case removeAttachment(filename: String) case renumberChildren // order-maintenance sweep (compaction) /// A loose file being moved out of a card folder into its `attachments/` — the loose-file /// carve-out's write (01-storage-format.md § Fractal layout ▸ Rules, settled 2026-07-28). /// /// Its own case rather than a fold into `.importAttachment`, on `.rename`'s and /// `.duplicateBoard`'s reasoning: nothing was *imported* — no file crossed into the board, the /// user dropped nothing, and a banner saying the app "couldn't import 'notes.txt'" would /// describe a gesture that never happened. `filename` is the name as it sat beside `index.md`, /// never the Finder-renamed one it would have landed under. case relocateLooseFile(filename: String) /// The board-root `CLAUDE.md` being written or upgraded — and the rescue move that precedes it /// when a markerless one has to be displaced to `CLAUDE.user.md` (08-agent-integration.md ▸ The /// agent guide; `AgentGuide`). /// /// **No payload**, unlike every other case that names something: there is exactly one guide per /// board, its filename is fixed, and it is not an item with a title. Its own case on /// `.relocateLooseFile`'s reasoning, doubled — this is work the *app* started on its own, for a /// file the user did not create, does not own and may not know exists; folding it into any /// gesture's phrasing would name an act that never happened. /// /// One case rather than two (the move and the write) deliberately: they ride one bracket and /// have one outcome the user could care about — the board's guide is not up to date — and a /// 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 withheld duplicate id being reminted — the duplicate-id heal's write (01-storage-format.md /// § Fractal layout ▸ Rules, re-ruled 2026-07-29 from its former banner gate). /// /// **Named for the Repair verb**, which is 06-history-undo.md's vocabulary for exactly this act /// ("Repair duplicate of 'Fix login'" — the heal commit's own name) and survived the re-ruling /// intact: what changed is who starts it, not what it is called. /// /// Its own case on `.relocateLooseFile`'s and `.displaceClaimedName`'s reasoning: this is work the /// *app* started on its own, on a folder the user copied in Finder without knowing it would /// collide, and a banner saying the app "couldn't move the item" would name a gesture that never /// happened. `title` is the withheld item's as the load found it — the name the user would /// recognize, and the one the successful notice uses. case repairDuplicateID(title: 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 /// vocabulary's standing reasoning: the user clicked a checkbox, and a banner telling them the /// app could not "restyle" or "rename" the card would name a gesture that never happened. It is /// also the app's only *body* write, which is worth being able to see in a log at a glance. case toggleTask(title: String?) /// The card window's Edit buffer being saved — the debounced tick, the flush that leaves Edit, /// and the flush that closes the window (05-card-window.md ▸ Edit). /// /// Its own case beside `.toggleTask` rather than folded into it, on the vocabulary's standing /// reasoning: both write a body, but one is a checkbox the user ticked and the other is prose /// they typed, and a banner telling someone the app "couldn't tick the checkbox" after they /// wrote three paragraphs would name a gesture that never happened. `title` is the card's title /// as the read that preceded the write found it — the name on the window they are typing in. case editBody(title: String?) /// The card window's raw-source Apply — the whole `index.md` replaced with the text the user /// typed (05-card-window.md ▸ Raw source outlet). /// /// Its own case beside `.editBody`, on the vocabulary's standing reasoning and then some: this is /// not a body write and not a frontmatter edit but the *file* being written, and it is the one /// operation whose bytes the app did not compose. A banner saying the app "couldn't save the /// card" would describe the Edit buffer the user was not in. The word the user pressed is /// **Apply**. /// /// `title` is the card's title as the file *currently* reads it — the name on the window — never /// the one the buffer proposes, which may be a title that never landed (`.rename`'s rule). It is /// also the operation `readRawSource` carries on the way *in*; that read's failures reach an /// alert rather than the banner, so the Apply phrasing is never shown for one. case rawSource(title: String?) /// Fills in the title once the Writer has read it off the document the operation is acting /// on — identity for the cases with no title slot at all: `createBoard`/`createLane`/ /// `createCard` are minting a file, not reading one; `importAttachment`, `removeAttachment` and /// `relocateLooseFile` carry a filename, which is the name the user is looking at and the only /// one their banner should say; `listAttachments`, `renumberChildren` and `agentGuide` name no /// single item. /// Called once, right where the operation's `readDocument` succeeds — `updateIndex` itself /// (which covers every case that funnels through it: renumber, delete, restore, style, and /// the tail end of move/copy) and the move/copy pre-flight, before the folder travels or the /// copy materializes. Every failure past that point reuses the enriched value, because the /// case is immutable once a caller has it in hand — there is nothing to "forget" later. public func withTitle(_ title: String?) -> WriteOperation { switch self { // `.repairDuplicateID` is identity here even though it carries a title: the remint never // opens an `index.md` — it is a rename — so there is no `readDocument` to enrich from, and // its title arrives already filled in from the load that detected the duplicate. case .createBoard, .createLane, .createCard, .importAttachment, .listAttachments, .removeAttachment, .renumberChildren, .relocateLooseFile, .agentGuide, .displaceClaimedName, .repairDuplicateID: self case .move: .move(title: title) case .reorder: .reorder(title: title) case .copy: .copy(title: title) case .delete: .delete(title: title) case .purge: .purge(title: title) case .migrateTombstone: .migrateTombstone(title: title) case .style: .style(title: title) case .resize: .resize(title: title) case .rename: .rename(title: title) case .duplicateBoard: .duplicateBoard(title: title) case .saveAsTemplate: .saveAsTemplate(title: title) case .toggleTask: .toggleTask(title: title) case .editBody: .editBody(title: title) case .rawSource: .rawSource(title: title) } } /// A short imperative phrase — `"move 'Fix login'"`, `"create card"`, `"import attachment /// 'photo.png'"` — for logs and diagnostics **only**: `BoardWriteError.description` (test /// failures, `po error`, console output), never the banner's text. The banner owns every /// word a user sees and switches exhaustively over the case itself to produce it /// (02-architecture.md § Write-failure surfacing); this description exists purely so a /// developer reading a raw error gets English without the banner layer's help. public var description: String { switch self { case .createBoard: "create board" case .createLane: "create lane" case .createCard: "create card" case let .move(title): Self.phrase("move", title) case let .reorder(title): Self.phrase("reorder", title) case let .copy(title): Self.phrase("copy", title) case let .delete(title): Self.phrase("delete", title) case let .purge(title): Self.phrase("purge", title) case let .migrateTombstone(title): Self.phrase("migrate the legacy 'deleted' key on", title) case let .style(title): Self.phrase("style", title) case let .resize(title): Self.phrase("resize", title) case let .rename(title): Self.phrase("rename", title) case let .duplicateBoard(title): Self.phrase("duplicate board", title) case let .saveAsTemplate(title): Self.phrase("save as template", title) case let .importAttachment(filename): "import attachment '\(filename)'" case .listAttachments: "list attachments" case let .removeAttachment(filename): "move attachment '\(filename)' to the Trash" 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 .repairDuplicateID(title): Self.phrase("repair the duplicate id of", title) 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) } } private static func phrase(_ verb: String, _ title: String?) -> String { guard let title else { return verb } return "\(verb) '\(title)'" } } // MARK: - Error /// A write that did not happen, said out loud: which operation, which file, and why — /// the vocabulary 02-architecture.md § Write-failure surfacing renders in the banner /// ("Couldn't move 'Fix login' — disk full"). Nothing here is swallowed or retried behind the /// user's back; a one-shot action fails once and waits for them to act again. public struct BoardWriteError: Error, Sendable, Equatable, CustomStringConvertible { /// What was being attempted when the write failed — the closed `WriteOperation` vocabulary, /// not a string (settled, 02-architecture.md § Write-failure surfacing): the banner switches /// exhaustively over this case to produce its user-facing phrasing, so this field carries no /// English of its own — that lives only in `WriteOperation.description`, for logs. public let operation: WriteOperation /// The file or folder involved. Absolute at this layer: the writer works in URLs and has no /// board root to be relative to (contrast `BoardLoadError.path`, which is root-relative). public let path: String public let reason: Reason public var description: String { "\(operation.description): \(path): \(reason.description)" } public enum Reason: Sendable, Equatable, CustomStringConvertible { /// The file is missing, is not UTF-8, or its frontmatter does not parse — `message` /// carries the specifics. A rewrite the app cannot round-trip is not attempted. case unreadable(message: String) /// The settled readable-but-uneditable refusal (01-storage-format.md § Frontmatter): /// the file loads and renders, but its frontmatter has a shape the surgical editor /// cannot address, so writing it would risk corruption. Names the shape. case uneditableFrontmatter(FrontmatterDocument.UneditableShape) /// Any I/O failure writing the temp file or renaming it into place — disk full, /// permissions, volume error. The destination still holds its previous bytes. case io(message: String) /// **The bytes the edit aimed at are not what the caller was shown.** The file read /// cleanly and its frontmatter parsed — this is not `.unreadable` — but the surgical /// target moved: the checkbox at that offset is gone, or is already in the state the /// click would have produced (05-card-window.md ▸ Preview, `toggleTaskMarker`). /// /// Its own reason because the app's one *offset-addressed* write is the one place where /// "read fresh from disk" is not enough on its own: every other write names a key, and a /// key that moved is still the same key. case staleTarget(message: String) /// **The text the raw-source outlet was asked to write would not load** (05-card-window.md ▸ /// Raw source outlet: "Apply validates through the same fail-fast parse the loader uses … /// before writing byte-for-byte"). Carries the loader's own error — path, reason and line /// number — because the alert that shows it is the design's "detailed alert", and a /// re-worded copy would be a second, worse taxonomy. /// /// Distinct from `.unreadable`, which is about the file **on disk**: here disk is fine and /// the *proposal* is not, so nothing was attempted and nothing changed. case invalidSource(BoardLoadError) public var description: String { switch self { case let .unreadable(message): "unreadable: \(message)" case let .uneditableFrontmatter(shape): "frontmatter cannot be edited in place: \(shape.description)" case let .io(message): message case let .staleTarget(message): message case let .invalidSource(error): "the source text wouldn't load: \(error.description)" } } } }