import Foundation import os /// 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 that rewrites *content*. 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. The one class of write that does neither is the **order-only rewrite** /// (`WriteOperation.rewritesOrderOnly` — the reorders-don't-stamp rule). /// - **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 reorders-don't-stamp predicate /// /// Step 3 is **skipped for an order-only rewrite** (01-storage-format.md § Frontmatter ▸ /// `modified`'s scope — ruled 2026-07-29 as moves-don't-stamp, refined 2026-07-30 to the /// container-change predicate). `order` is logically the *container's* property — a relationship /// among a lane's members that the format happens to store inside each member's file — so a /// rewrite that only restates position touches no content: no `modified` stamp, and no /// `modified-by` clear (the two are paired; attribution cannot change when content didn't). /// /// **The predicate is the operation's, not a parameter** (`WriteOperation.rewritesOrderOnly`): /// the vocabulary already draws the line this rule needs — `.reorder` is by construction the /// same-container case (`moveItem` decides it from the two URLs before it touches disk) and /// `.renumberChildren` is the whole-lane rescale. Deriving it here rather than asking each call /// site means no caller can forget, and the rule stays one exhaustive switch a suite can pin /// without a filesystem. /// /// **There is no trash branch anywhere**, deliberately: a move into or out of `.trash/` changes /// the item's container, so it stamps for the same reason a cross-lane move does. The trash move /// is the container rule's plainest instance rather than an exception to a rule about moves. /// /// 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)) // The reorders-don't-stamp predicate, read off the operation. An order-only rewrite restates // the container's own arrangement and leaves both provenance keys exactly as it found them — // a standing `modified-by` survives a reorder, which is the pairing 01 spells out. if !operation.rewritesOrderOnly { 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? { // The URL form, deliberately: it is the only one that can tell the board's `.trash/` from a // comment thread's own `comments/.trash/`, which share a name. switch IntegrityRules.placement(ofFolder: folder) { case .card: return .card case .lane: return .lane case .comment: return .comment 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. /// Internal rather than `private`, with `renameFolder` and for its reason: a posted comment's /// identity is minted by this exact rule. 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. /// **Internal rather than `private`**: the comment writer (`CommentWriter.swift`) is this /// file's own extension one level down and runs the identical pre-flights. 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. /// - **Nothing is stamped.** A rescale is order-only, so no sibling's `modified` moves and no /// sibling's `modified-by` is cleared (01-storage-format.md § Ordering, verbatim: "order-only /// rewrites, so no `modified` stamp and no `modified-by` clear"). That falls out of /// `.renumberChildren` answering `rewritesOrderOnly` rather than being arranged here — which is /// what keeps a whole lane's worth of bookkeeping from looking like a whole lane's worth of /// edits to the card window, to a future auto-purge, and to an agent's own attribution. /// /// 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`"). `order` is the /// caller's explicit rank (a drop between two siblings), or `nil` to append after the /// destination's visible siblings. /// /// **Whether that rewrite stamps is the container question** (§ Frontmatter ▸ `modified`'s scope, /// refined 2026-07-30 — `WriteOperation.rewritesOrderOnly`), and this call is where it is /// answered for every move in the app: the same-parent degenerate path below is a `.reorder` and /// rewrites `order` alone, while a real move — cross-lane, cross-board, into or out of `.trash/` /// — is a `.move` and stamps `modified` and clears `modified-by` like any content write. The /// branch that already exists for the *rank arithmetic* is therefore the whole of the stamping /// rule too; there is no second test, and pointedly no trash case. /// /// **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. /// Internal rather than `private`: a comment thread's own children are enumerated by the same /// rule, one level down. 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. /// /// Internal rather than `private`: posting a comment is this rename and nothing else /// (`comments/.draft/` → a fresh identity), and its undo is the same rename read backwards. 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. /// Internal rather than `private`: the comment purge checks its container the same way. 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). /// /// Plus the tracker sever, which is the copy contract's third clause: every folder this /// materializes drops the reserved `remote`/`remote-state` keys, at every level /// (`applyCopyContract`). /// /// ## A copy is a transaction (ruled 2026-07-29) /// /// **The whole subtree is preflighted for stampability before anything is materialized**, and a /// copy that cannot honor the contract on one nested card refuses whole, loudly, naming that card /// (01-storage-format.md § Frontmatter: "every copy flow that rewrites descendants' `index.md` … /// preflights the entire subtree and refuses whole, loudly, naming the offending item — never a /// partial copy, never a silently unstamped descendant"). /// /// This **retired the former root-strict/nested-lenient split**, which copied an unreadable or /// readable-but-uneditable nested `index.md` byte-verbatim and simply skipped its stamp. The /// leniency read as kindness and was in fact the one verdict 01's doctrine forbids: "leniency is /// recovering recoverable issues through reliable heuristics — never accepting loss that could /// surprise the user … 'proceed partially, lose a little' is never a verdict". A silently /// unstamped descendant carries a stale `modified-by` and, since 2026-07-29, a *live tracker /// claim* into a second local object — which is exactly the surprise. The finest-grain precedent /// covers identity collisions (a per-folder repair that loses nothing), not skipped contract work. /// /// The preflight runs over the **source**, so a refusal costs nothing on disk: nothing is copied, /// nothing is renamed, nothing has to be cleaned up. A folder with **no `index.md`** — /// interrupted-create residue — is not an offense: it is copied and reminted like any other and /// simply has nothing to stamp, 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. After /// a clean preflight the only thing left to fail is disk, and that is what this catch is for. /// 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) // The transaction's preflight, over the *source*: a subtree that cannot honor the copy // contract refuses here, where nothing has been materialized and there is nothing to undo. try checkCopiedDescendantsAreStampable(of: 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 { // **`comments/.trash/` never crosses a copy boundary** (01-storage-format.md § Enhanced // schema: "stripped at every copy boundary … a copy must not carry ghosts no window // session will ever purge"). Before the remint, so the strip walks the paths the source // had rather than minted ones nobody has seen. try stripCommentTrash(under: root, operation: operation) var copied: [URL] = [] try remintDescendants(of: root, collecting: &copied, operation: operation) let now = Date() try updateIndex(inItemFolder: root, operation: operation) { document in applyCopyContract(to: &document, stamps: stamps, now: 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. /// **The thread comes too** (01-storage-format.md § Enhanced schema: "**Copies carry the thread** /// — a copy is a fork, and dropping a subtree would be the one place a copy loses content; comment /// folders remint like every copied folder"). `comments/` is not identity-shaped, so the recursion /// above cannot reach through it; the second loop is that one step, and it does not recurse /// because a comment has no identity-bearing children of its own (flat, this iteration). /// /// **`comments/.draft/` is not reminted, and that is not an omission**: it is a dot-named folder, /// so `directoryCandidates` never offers it, and it has no identity to mint away from. It carries /// verbatim, which is the ruling ("copies and the trash carry it like any comment folder /// (fork-lossless)"). `comments/.trash/` is gone before this runs (`stripCommentTrash`). 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) } let thread = folder.appendingPathComponent(IntegrityRules.commentsFolderName, isDirectory: true) for comment in childCandidates(of: thread) { let fresh = freshUUIDName(in: thread, avoiding: []) try renameFolder(comment, toSiblingNamed: fresh, operation: operation) copied.append(thread.appendingPathComponent(fresh, isDirectory: true)) } } /// **Removes every `comments/.trash/` in a copied tree** — the copy boundary's strip /// (01-storage-format.md § Enhanced schema, ruled 2026-07-29). Called on the *destination*, so /// nothing a user still owns is ever removed by it; the source's thread trash stays exactly where /// it is, waiting for its own window's close purge. /// /// The reach is the root plus its identity-bearing descendants, which is every folder that can /// have a thread: a comment has none, and a board root's own `comments/` would be a stray. /// /// **Internal rather than `private`**: the clipboard's staging snapshot is a copy boundary the /// Writer does not perform (`ClipboardStore.stage`) and strips through this same call — "stripped /// at every copy boundary (clipboard staging, Duplicate, Save as Template)" is only one rule if it /// is one function. static func stripCommentTrash(under folder: URL, operation: WriteOperation) throws(BoardWriteError) { for item in [folder] + identityDescendants(of: folder) { let trash = item .appendingPathComponent(IntegrityRules.commentsFolderName, isDirectory: true) .appendingPathComponent(IntegrityRules.commentTrashFolderName, isDirectory: true) guard IntegrityRules.node(at: trash) != nil else { continue } do { try FileManager.default.removeItem(at: trash) } catch { throw BoardWriteError( operation: operation, path: trash.path, reason: .io(message: "could not remove the copied comment trash: \(error.localizedDescription)") ) } EchoLedger.current?.recordDeletion(at: trash) } } /// **The copy contract's frontmatter edits**, applied to every folder an item-level copy /// materializes — root and descendants alike, in one place so the two can never disagree about /// what a copy owes. /// /// Two clauses, and `updateIndex` adds the third: /// /// - **`created` per `stamps`** — `.fork` keeps it (a copy really was created when its original /// was), `.born` restamps it, because a board or card made from a template is born today /// (09-templates.md). /// - **The reserved tracker keys go** — `remote` and `remote-state`, at every level /// (01-storage-format.md § Fractal layout ▸ Rules, ruled 2026-07-29: "**Item-level copies sever /// tracker identity** … because two local objects must never both claim to be the same remote /// object"). Content preserved, mapping severed. Nothing reads the keys until Teams, and that is /// the argument rather than an objection: the copies made today are the boards Teams will meet, /// so the sever costs nothing now and spares a stale double-claim later. `FrontmatterDocument.remove` /// takes every occurrence, so a hand-duplicated key cannot leave a twin behind to resurrect the /// claim. /// - **`modified` stamped and `modified-by` cleared** come from `updateIndex`, because a copy is an /// app write like any other (§ Frontmatter) — not something this function has to remember. /// /// `order` is deliberately absent: the copied *root* takes its new rank from its caller, and a /// nested item keeps its rank among its own siblings, which travelled with it. /// /// **Whole-board forks do not call this at all** — Duplicate and Save as Template carry bytes /// verbatim, GUIDs, timestamps and tracker keys included (01 ▸ Identity lifecycle's carve-out). static func applyCopyContract( to document: inout FrontmatterDocument, stamps: CopyStamps, now: Date ) { if case .born = stamps { document.set(FrontmatterKeys.created, to: .date(now)) } document.remove(FrontmatterKeys.remote) document.remove(FrontmatterKeys.remoteState) } /// **The copy transaction's preflight**: every identity-bearing folder beneath `folder` whose /// `index.md` the copy contract will rewrite, checked for readability and editability *before* /// anything is materialized — and the first offender refuses the whole copy, named /// (01-storage-format.md § Frontmatter, ruled 2026-07-29: "preflights the entire subtree and /// refuses whole, loudly, naming the offending item"). /// /// **Naming the offender is the point**, so the thrown error is re-enriched with *that* item's /// title rather than the copy root's: a refusal reading "Couldn't copy 'Sprint 12'" when the /// unwritable file is one card inside it would send the user looking in the wrong place. A file /// that cannot be read at all has no title to offer, and its path — which the error always /// carries — is then the whole of what can honestly be said about it. /// /// **A folder with no `index.md` is not an offense** and is skipped: it is interrupted-create /// residue, the loader skips it too (`.missingIndex`), and there is no contract work to fail. /// /// **Comment folders are deliberately outside this preflight** (added 2026-07-30 with the comment /// storage, and worth stating because it is a *narrowing* of a 2026-07-29 ruling): 01's copy /// transaction says "refuses whole, loudly, naming the offending item", and its enhanced-schema /// section says "**comment defects never refuse the board** — worst case is the stray posture … /// a broken leaf annotation must not brick a load; deliberate, proportionate divergence from card /// fail-fast". A ⌘V that refuses because one comment on one card inside a pasted lane has /// hand-broken frontmatter is that divergence read the other way round. So the walk stays /// `identityDescendants`' — cards and lanes — and a comment the contract cannot be applied to is /// copied verbatim with a log line instead (`stampCopiedComment`). /// /// **Internal rather than `private`**: template instantiation preflights its own tree with this, /// for `remintDescendants`' reason — one definition of what a copy owes its descendants. static func checkCopiedDescendantsAreStampable( of folder: URL, operation: WriteOperation ) throws(BoardWriteError) { for descendant in identityDescendants(of: folder) { let indexURL = descendant.appendingPathComponent(BoardLoader.indexFileName) guard FileManager.default.fileExists(atPath: indexURL.path) else { continue } let document = try readDocument(at: indexURL, operation: operation) try checkEditable(document, at: indexURL, operation: operation.withTitle(document.title.value)) } } /// Every **card or lane** beneath `folder`, depth first — `remintDescendants`' recursion with the /// renaming taken out, so the preflight and the remint cannot disagree about which folders a copy /// materializes as *items*. Comment folders are not here, by the carve-out /// `checkCopiedDescendantsAreStampable` states; it is also exactly the right reach for /// `stripCommentTrash`, since a thread lives under a card and nowhere else. private static func identityDescendants(of folder: URL) -> [URL] { var found: [URL] = [] for child in childCandidates(of: folder) { found.append(child) found.append(contentsOf: identityDescendants(of: child)) } return found } /// Stamps one copied folder below the root — **strictly**, since the preflight has already cleared /// the whole subtree (`checkCopiedDescendantsAreStampable`): an `index.md` that cannot be read or /// edited here is a disk failure between the two reads, not a shape to tolerate, and it fails the /// copy like any other mid-flight failure (whose partial result the caller removes wholesale). /// /// The former best-effort posture — copy it verbatim, skip its stamp — is retired with the /// root-strict/nested-lenient split (see `copyItem`): a silently unstamped descendant is a /// descendant still carrying somebody else's `modified-by` and, since 2026-07-29, somebody else's /// tracker claim. /// /// A folder with **no `index.md`** is still skipped, and for a different reason entirely: there is /// nothing there to stamp (interrupted-create residue, which the loader skips too). /// /// **Internal rather than `private`**, with `remintDescendants` and for its reason: an /// instantiated board's lanes and cards are stamped by this exact rule. 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) else { return } // The one lenient branch, and the preflight's own carve-out read from the write side: a // comment was never checked, so a comment that cannot be stamped is a *tolerated* defect // rather than a disk failure — it copies verbatim, keeping whatever `remote` and // `modified-by` it carried, and says so in the log (01-storage-format.md § Enhanced schema, // "comment defects never refuse … tolerated, logged"). guard !isCommentFolder(folder) else { do { try updateIndex(inItemFolder: folder, kind: .comment, operation: operation) { document in applyCopyContract(to: &document, stamps: stamps, now: now) } } catch { logger.warning( "\(folder.path, privacy: .public): copied comment left unstamped — \(error.description, privacy: .public)" ) } return } try updateIndex(inItemFolder: folder, operation: operation) { document in applyCopyContract(to: &document, stamps: stamps, now: now) } } /// Whether `folder` is a comment — its parent is a card's `comments/`. Position, like every other /// kind question here (`IntegrityRules.placement`). static func isCommentFolder(_ folder: URL) -> Bool { IntegrityRules.placement(ofFolder: folder) == .comment } static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "writer") // 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). A board root and a stray are refused here, and a lane takes /// the sibling door (`deleteLaneToTrash`) — same move, different guard and a different `kind` /// to stamp, which is exactly the pair `.trash/`'s flat container needs told apart. /// 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** — and it needs no exception to earn /// it. The trash move changes the card's *container*, which is the whole predicate /// (`WriteOperation.rewritesOrderOnly`, refined 2026-07-30): "deletion is an edit to the item's /// story", so it stamps exactly as a cross-lane move does, 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 — and why the reorders-don't-stamp rule needs no /// trash carve-out to coexist with this call. /// /// **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 moveIntoTrash( at: cardFolder, inBoard: boardRoot, kind: .card, order: order, operation: .delete(title: nil), removingLegacyKey: false ) } /// **Deleting a lane: the same physical move into `/.trash/`** (01-storage-format.md /// § Deletion, lanes joined 2026-07-29 — "retiring the design's sole destructive delete"; /// 03-board-ui.md § Trash: "deleting a lane moves its folder — subtree intact — into `.trash/`, /// exactly as a card moves"). /// /// `deleteCardToTrash`'s body with two differences, and they are the whole of what a lane is: /// /// - **The guard is `checkIsLaneFolder`** — UUID-shaped directly under a board root, which /// refuses a card, a board root, a stray, and notably a folder already in `.trash/`. /// - **`kind: lane` is stamped**, not derived. The rank rewrite is a `updateIndex` on a folder /// that is by then *inside* `.trash/`, where position cannot answer and shape would answer /// *wrongly* for the one lane that most needs the key: an **empty** lane is shape-identical to /// a card (01's own "honest limit"). The caller knows what it moved, so it says so — which is /// also the backfill the ruling asks of this write ("`kind: lane` … backfilled on touch when /// absent … the trash move's rank mint included"). /// /// **The subtree rides along untouched**: nothing beneath the lane is read or rewritten, so its /// cards, their `attachments/` and every stray arrive byte-identical and come back with it on /// restore — which is what makes the inverse an ordinary move rather than a replay of captured /// bytes (13-native-undo.md ▸ Interaction with the trash). /// /// - Returns: the lane's identity, unchanged. @discardableResult public static func deleteLaneToTrash( at laneFolder: URL, inBoard boardRoot: URL, order: Double ) throws(BoardWriteError) -> ItemID { try moveIntoTrash( at: laneFolder, inBoard: boardRoot, kind: .lane, 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 moveIntoTrash( at: cardFolder, inBoard: boardRoot, kind: .card, order: order, operation: .migrateTombstone(title: nil), removingLegacyKey: true ) } /// The shared body of the three moves into `.trash/` — `deleteCardToTrash`, `deleteLaneToTrash` /// and `migrateTombstonedCard`. See the first for the sequence, the second for what a lane's /// `kind` is doing here, and the third for what `removingLegacyKey` adds. /// /// **`kind` is both the guard and the stamp**: it picks which shape check the item must pass on /// the way out, and it is handed to `updateIndex` so the arrived entry's `kind` is written from /// what the caller *moved* rather than guessed from what the flat container makes it look like. private static func moveIntoTrash( at itemFolder: URL, inBoard boardRoot: URL, kind: IntegrityRules.ObjectKind, order: Double, operation initialOperation: WriteOperation, removingLegacyKey: Bool ) throws(BoardWriteError) -> ItemID { var operation = initialOperation try checkIsDirectory(itemFolder, describedAs: kind == .lane ? "lane folder" : "card folder", operation: operation) try checkIsDirectory(boardRoot, describedAs: "board folder", operation: operation) switch kind { // `.board` and `.comment` are unreachable — the three callers pass `.card` or `.lane`, and // neither a board nor a comment is a thing the *board's* trash ever holds (a deleted comment // moves into its own thread's `comments/.trash/`). Both take the card guard, which refuses // them both loudly rather than letting an unexpected caller through. case .lane: try checkIsLaneFolder(itemFolder, operation: operation) case .card, .board, .comment: try checkIsCardFolder(itemFolder, operation: operation) } operation = try checkIndexIsRewritable(inItemFolder: itemFolder, 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 = itemFolder.lastPathComponent let arrived = trash.appendingPathComponent(name, isDirectory: true) do { try FileManager.default.moveItem(at: itemFolder, to: arrived) } catch { throw BoardWriteError( operation: operation, path: itemFolder.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 item was, the shown-trash side sees an arrival where it went. EchoLedger.current?.recordMove(from: itemFolder, to: arrived) try updateIndex(inItemFolder: arrived, kind: kind, 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) } /// Permanently removes one **entry** from the trash — the trash's own **Delete** /// (03-board-ui.md § Trash: "on a trash selection, Delete (⌫/⌘⌫) is permanent … in the trash it /// removes the folder"). /// /// `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 item. `purgeItem` /// (unconstrained) is for the create-undo's own removal, not this call. /// /// **A trashed lane purges whole, freight and all** (lanes joined the trash 2026-07-29): the /// removal is recursive, so "permanent delete … walks lane subtrees" needs no walk of its own /// here — what the confirmation must *count* before this runs is `TrashModel`'s job, off the /// snapshot. Which kind the entry is therefore never comes up: the container and the shape are /// the whole check, exactly as they were when only cards lived here. /// /// An already-gone folder is success, `purgeItem`'s rule. public static func purgeTrashEntry(at entryFolder: URL, inBoard boardRoot: URL) throws(BoardWriteError) { let operation = WriteOperation.purge(title: nil) guard FileManager.default.fileExists(atPath: entryFolder.path) else { return } try checkIsUUIDShaped(entryFolder, operation: operation) guard isSameLocation(entryFolder.deletingLastPathComponent(), trashFolder(inBoard: boardRoot)) else { throw BoardWriteError( operation: operation, path: entryFolder.path, reason: .unreadable(message: "folder is not in this board's trash") ) } do { try FileManager.default.removeItem(at: entryFolder) } catch { throw BoardWriteError( operation: operation, path: entryFolder.path, reason: .io(message: "could not remove folder: \(error.localizedDescription)") ) } EchoLedger.current?.recordDeletion(at: entryFolder) } /// **Empty Trash** (⇧⌘⌫, 03-board-ui.md § Trash): permanently removes every entry in /// `/.trash/` — cards and trashed lanes alike, **lane subtrees walked** by the /// recursive removal. Returns what it removed, in folder-name order. /// /// **The entry folders, not the container.** The design says it "purges the whole `.trash/`", /// and the entries 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 an entry keeps the count honest (the confirmation names cards and /// lane freight) 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 entry, 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 entry in childCandidates(of: trashFolder(inBoard: boardRoot)) { do { try FileManager.default.removeItem(at: entry) } catch { throw BoardWriteError( operation: operation, path: entry.path, reason: .io(message: "could not remove folder: \(error.localizedDescription)") ) } EchoLedger.current?.recordDeletion(at: entry) purged.append(ItemID(rawValue: entry.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") ) } } /// Physical removal — the create-undo's own primitive (13-native-undo.md: "create → remove the /// created folder"): 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 an undone create's folder was never trashed to begin with. /// /// **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, so they cannot /// 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 { // `copyItem`'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) return try importFiles(sourceURLs, intoAttachmentsOf: cardFolder, batchOperation: batchOperation) } /// **Steps 2–4 of `importAttachments`, with the shape guard left to the caller** — the copy /// itself, which is identical wherever an `attachments/` folder hangs. /// /// It exists because a *comment* has an `attachments/` too (01-storage-format.md § Enhanced /// schema — "a card's anatomy one level down"), and comment attachments author in-window /// (05-card-window.md ▸ The comments column, ruled 2026-07-29). The alternative was a second /// importer beside this one, which is exactly what `CardAttachments`' own note forbids for the /// card level: "one import path, one set of banners, one Finder-style collision rename". The /// generalization is therefore the *smallest* one that keeps that true — the folder shape is what /// differs between a card and a comment, and it is the only thing the callers still decide. /// /// Everything the batch promises is here rather than at either entry point: `attachments/` minted /// on first import, each source validated before it is touched, the Finder-style collision-free /// name, the non-atomic copy with best-effort cleanup, and the import receipt. static func importFiles( _ sourceURLs: [URL], intoAttachmentsOf itemFolder: URL, batchOperation: WriteOperation ) throws(BoardWriteError) -> [ImportedAttachment] { let attachmentsFolder = itemFolder.appendingPathComponent(attachmentsFolderName, isDirectory: true) do { try FileManager.default.createDirectory(at: attachmentsFolder, withIntermediateDirectories: true) } catch { throw BoardWriteError( operation: batchOperation, path: itemFolder.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. /// Internal rather than `private`: every comment write is *about* a card, and reaches it /// through this same guard. 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). /// /// **Level-uniform** (extended 2026-07-29): the squatter's own `location` names the folder the /// claimed name lives in — the board root, or a card's folder for a file wearing `attachments`. That /// is the whole of the difference, which is the point of the ruling: one ladder, one notice, one /// write, whichever level the name is claimed at. A card whose folder has since gone takes the /// re-verification's `nil` path like any other vanished defect. /// /// - Parameter root: the board root. The squatter's location is resolved against it, so a board /// renamed since the load heals at its new location. /// - 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 container = squatter.location.folder(under: root) let occupied = container.appendingPathComponent(squatter.name) guard let found = IntegrityRules.node(at: occupied), found != squatter.expected else { return nil } let freed = freshName(for: squatter.name, in: container) let destination = container.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) return try trashAttachment(named: name, fromItemFolder: cardFolder, operation: operation) } /// `removeAttachment`'s body with the shape guard left to the caller — `importFiles`' split, for /// its reason: a comment's authoring chips carry Remove too, "to the **system** Trash — the /// sidebar row's rule" (05-card-window.md ▸ The comments column), and one Trash-not-delete /// promise is worth more than two implementations of it. /// /// The listing check, the never-hard-delete guarantee and the a-name-that-is-gone-is-not-a-failure /// rule all live here, so they hold at both levels without either caller restating them. static func trashAttachment( named name: String, fromItemFolder itemFolder: URL, operation: WriteOperation ) throws(BoardWriteError) -> URL? { guard BoardLoader.attachmentNames(in: itemFolder).contains(name) else { return nil } let fileURL = itemFolder .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. /// Internal rather than `private`: the comment writer's own discover-before-you-write pre-flight. 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). /// Internal rather than `private`: the comment writer reads through the same door, so the two /// cannot disagree about what "could not be read" means. 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)) } } /// Internal rather than `private`, with `readDocument` and for its reason. 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 } } /// 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?) /// ⌘V — **a paste that refused before it wrote anything** (04-interactions.md ▸ Clipboard, /// re-ruled 2026-07-29: "A paste whose staged snapshot is missing or unreadable refuses loudly — /// never degrades … the paste produces **nothing**, and a one-shot failure banner names it from /// the manifest's metadata"). /// /// **Its own case rather than a fold into `.copy`**, on `.rename`'s and `.duplicateBoard`'s /// reasoning: the user pressed *Paste*, and a banner telling them the app "couldn't copy 'Fix /// login'" would name a gesture they never made. It is also the one operation in this vocabulary /// the Writer itself never performs — a paste's *arrivals* are `.copy` and `.move` — because the /// refusal happens at the clipboard's preflight, before any arrival is materialized; the /// vocabulary grows with the surfaces, and the surface here is the refusal. /// /// `title` is the offending entry's, read off the manifest's own metadata (which is exactly what /// the embedded `index.md` is kept for now that it is never a materialization source), `nil` for /// an untitled item. case paste(title: String?) /// ⌫ / ⌘⌫ — a card (`deleteCardToTrash`) or a lane (`deleteLaneToTrash`) moving into `.trash/`. /// One word for one gesture: since lanes rejoined the trash (2026-07-29) both are the same /// physical move, and neither is destructive. /// /// **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 (01-storage-format.md § Deletion: "cards migrate, lanes ignore"; the lane half /// retired 2026-07-29 with the lane trash, so this operation has one performer left). /// /// 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. 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?) // MARK: The comment family // // Five operations, one path-shaped verb family (06-history-undo.md ▸ Commit messages, the // vocabulary 01-storage-format.md § Enhanced schema names: "Comment on '⟨card title⟩'" / "Edit // comment on…" / "Delete comment on…" / "Draft comment on '⟨card⟩'"). **Every one of them carries // the *card's* title, not the comment's** — a comment has no `title` key at all (§ Enhanced // schema: "No `title`, no `order`"), and the thing a user recognizes is the card they are // commenting on. That is also why all five are identity in `withTitle`: there is no document to // enrich from, and the value arrives already filled in from the window the gesture came from. /// The composer's slow-cadence save into `comments/.draft/` — blur, window close, quit, and the /// ~30 s tick (05-card-window.md ▸ The comments column: "Draft saves are slow-cadence, never /// prompted"). Its own case rather than a fold into `.editComment`, on the vocabulary's standing /// reasoning: a draft is not yet a comment, and telling someone the app "couldn't edit a comment" /// after they typed one that has never been posted would name a thing that does not exist. case saveCommentDraft(title: String?) /// ⌘↩ — the draft renamed to a fresh identity and restamped, one bracket (§ Enhanced schema: /// "posting renames it to a fresh lowercase UUID and restamps `created`/`modified` in the same /// bracket — chronology is post time, not drafting time — one commit"). case postComment(title: String?) /// An inline comment edit session's save — the body-edit session in miniature (05 ▸ The comments /// column). Its own case beside `.editBody` for that case's reason: both write a body, but one is /// the card the window is about and the other is one annotation on it. case editComment(title: String?) /// A comment moving into `comments/.trash/` — "delete = move into `comments/.trash/`", immediate, /// no confirm, undone by the ordinary move back (§ Enhanced schema; 13-native-undo.md). /// /// **The inverse rides this same case**, deliberately: the restore is a move with no gesture of /// its own — the user pressed ⌘Z on a delete — and `.delete`'s own "there is no `restore` case" /// note is the precedent one level up. case deleteComment(title: String?) /// `comments/.trash/` emptied — at card-window close, and as the crash-residue sweep at the next /// open (§ Enhanced schema). /// /// **No payload, unlike its four siblings**, and for `.agentGuide`'s reason: this is bookkeeping /// the app does on its own over a folder that is "never a UI surface", with one outcome nobody /// asked for and nothing to name. The path-shaped verb family has no word for it either. case purgeCommentTrash /// 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. // The comment family is identity for `.repairDuplicateID`'s reason, doubled: a comment's // `index.md` carries no `title` to enrich from, and the title these five hold is the *card's*, // filled in by the caller from the window the gesture came from. case .createBoard, .createLane, .createCard, .importAttachment, .listAttachments, .removeAttachment, .renumberChildren, .relocateLooseFile, .agentGuide, .displaceClaimedName, .repairDuplicateID, .saveCommentDraft, .postComment, .editComment, .deleteComment, .purgeCommentTrash: self case .move: .move(title: title) case .reorder: .reorder(title: title) case .copy: .copy(title: title) // Identity, like `.repairDuplicateID` and for its reason: a refused paste never opens an // `index.md`, so there is no `readDocument` to enrich from — its title arrives already filled // in from the manifest entry the refusal names. case .paste: self 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) } } /// **Whether this rewrite only restates the item's position among its container's members** — /// the reorders-don't-stamp predicate (01-storage-format.md § Frontmatter ▸ `modified`'s scope, /// ruled 2026-07-29 as moves-don't-stamp, refined 2026-07-30). /// /// One question decides it: **does the rewrite change the item's container?** If it does — a /// cross-lane move, a cross-board arrival, a move into or out of `.trash/` — the item's story /// changed (which lane a card lives in is state) and the write stamps `modified` and clears /// `modified-by` like any content write. If it does not — a card reordered among its lane's /// siblings, a lane reordered on the board, a renumber's whole-lane rescale — only `order` is /// rewritten: no stamp, no clear. Where an item *stands in line* is presentation, and `order` is /// logically the container's property that the format happens to store inside the member's file. /// /// **The vocabulary already draws the line**, which is why this is a property here rather than a /// flag threaded through `updateIndex`'s call sites: /// /// - `.reorder` is the same-container case by construction — `moveItem` decides it from the source /// parent and the destination parent before it touches disk (`isSameLocation`), and the two /// inverse paths that rewrite a rank directly (`BoardStore.setOrder`, the within-lane sort) are /// same-container for the same reason: a lane's parent is the board root, and a card the sort /// permutes never leaves its lane. /// - `.renumberChildren` is the midpoint-exhaustion rescale — "order-only rewrites, so no /// `modified` stamp and no `modified-by` clear" (01 § Ordering, verbatim). /// - **Everything else stamps.** `.move` covers every container change including the trash's, and /// there is deliberately **no trash case anywhere**: the trash move stamps because every /// container change stamps, so a branch for it would be a second rule saying the same thing. /// /// Exhaustive with no `default`, like every other switch over this enum: a new operation has to /// answer "does this rewrite content?" before it compiles. public var rewritesOrderOnly: Bool { switch self { case .reorder, .renumberChildren: true // The comment family writes content, never a rank: a comment has no `order` at all // (01-storage-format.md § Enhanced schema), so there is nothing here for the order-only // reading to be about — and `.deleteComment`'s move into `comments/.trash/` stamps for the // plain container reason its board-level twin does. case .createBoard, .createLane, .createCard, .move, .copy, .delete, .purge, .migrateTombstone, .style, .resize, .rename, .duplicateBoard, .saveAsTemplate, .paste, .importAttachment, .listAttachments, .removeAttachment, .relocateLooseFile, .agentGuide, .displaceClaimedName, .repairDuplicateID, .toggleTask, .editBody, .rawSource, .saveCommentDraft, .postComment, .editComment, .deleteComment, .purgeCommentTrash: false } } /// 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 .paste(title): Self.phrase("paste", 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) // The card's title, never the comment's — see the family's own note above. case let .saveCommentDraft(title): Self.phrase("save the comment draft on", title) case let .postComment(title): Self.phrase("post a comment on", title) case let .editComment(title): Self.phrase("edit a comment on", title) case let .deleteComment(title): Self.phrase("delete a comment on", title) case .purgeCommentTrash: "purge deleted comments" } } 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) /// **The bytes a paste was to reproduce are not there** — the staged clipboard snapshot is /// missing or unreadable, so the paste produces nothing rather than a hollowed item /// (04-interactions.md ▸ Clipboard, re-ruled 2026-07-29 — Finder's invariant, and 01's /// leniency doctrine: "proceed partially, lose a little" is never a verdict). /// /// **Payload-free on purpose.** There is exactly one thing to say about it, the banner owns /// the words (`BannerCenter.causePhrase`), and the offending item is already named by the /// operation's own title — so a free-form message here could only be a second, worse copy of /// a sentence that lives one layer up. That also keeps it distinct from `.unreadable`, whose /// message is a developer's diagnostic about a file the app *did* open. case clipboardContentGone 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)" case .clipboardContentGone: "the copied content is no longer staged" } } } }