Materialize the trash — storage layer

Phase 1 of the trash pivot: the file format learns .trash/. The loader
parses the reserved root container — cards only, one shared parseCard
for both containers so fail-fast, attachments, and verbatim documents
are literally the same code; absent means empty; symlinks and
lane-shaped nestings fall out as strays by construction. BoardModel
grows snapshot.trash as a plain rank-ordered card list — the container
has no identity to carry. Legacy deleted: keys keep flowing through
the retiring flag path so every tombstone consumer stays green, and
are additionally reported through LoadResult.legacyTombstones in the
loose-file idiom for phase 2's migration scheduling — nothing vanishes
from view before its folder has actually moved, which is also 01's
lock-deferral posture. Writer primitives land value-passing: move to
trash with caller-minted rank and the deliberate modified stamp,
tombstone migrations that surgically remove the key, physical lane
removal, per-card and whole-container purge that leaves strays
verbatim, and byte-faithful whole-subtree capture/recreate for lane
undo. Board-wide identity now spans the trash, so an import colliding
with a trashed UUID remints instead of colliding. The watcher already
delivered .trash events — isGitInternal tests a component, not a dot —
now stated and pinned rather than relied on.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
2026-07-28 15:55:40 -04:00
parent 96c4014fef
commit 4cf5f09d93
8 changed files with 1746 additions and 30 deletions
+557 -2
View File
@@ -589,6 +589,16 @@ public enum BoardWriter: Sendable {
identities.insert(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.insert(canonicalIdentity(card.lastPathComponent))
}
return identities
}
@@ -897,7 +907,496 @@ public enum BoardWriter: Sendable {
return ItemID(rawValue: root.lastPathComponent)
}
// MARK: - Tombstone
// MARK: - The materialized trash
/// `<boardRoot>/.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 `<board-root>/.trash/`** (01-storage-format.md
/// § Deletion, resettled 2026-07-28; 03-board-ui.md § Trash). The tombstone is retired no
/// key is written, nothing is flagged, and the card becomes "an ordinary card in a special
/// place".
///
/// The sequence, which is the contract:
///
/// 1. **`cardFolder` must be a card** (`checkIsCardFolder`, the stricter guard: UUID-shaped
/// *under* a UUID-shaped parent). This is what makes "lanes are never trashed" structural
/// rather than a policy the caller has to remember a lane, a board root and a stray are
/// all refused here, and lane deletion has its own call (`removeLane`).
/// 2. **Pre-flight the card's `index.md`** (`checkIndexIsRewritable`) the move rewrites it
/// at the destination, so a file that cannot be round-tripped refuses *before* the folder
/// travels. `moveItem`'s discover-before-you-write rule, for its reason.
/// 3. **`.trash/` is created if absent** it is minted by the first delete, so most boards
/// meet it here.
/// 4. **Move the folder.** Nothing beneath it is read or rewritten, so `attachments/`, strays
/// and every byte arrive unchanged, exactly as in an ordinary move.
/// 5. **Rewrite `order` to `order`, and stamp.**
///
/// **`order` is the caller's, always** deliberately not defaulted and deliberately not
/// computed here. Entry is at the *top* ("every arrival lands at the trash's topmost
/// position, minting an `order` rank above the current top"), which is
/// `Ranks.insertAtHead(ofVisible:)` over the trash's current ranks a question about the
/// *snapshot*, which the store holds and this stateless layer does not. Value-passing keeps
/// the seam: the Writer takes a rank, the store computes it.
///
/// **The `modified` stamp is the point, not a side effect.** Deletion is the one exception to
/// moves-don't-stamp "deletion is an edit to the card's story" and the stamp is what a
/// future age-based auto-purge reads. It falls out of `updateIndex` here rather than being
/// asked for, which is why there is nothing extra in step 5.
///
/// **Collision inside `.trash/` is impossible by construction**, and it is checked anyway. The
/// card is a resident of this very board, and board-wide uniqueness now spans lanes *and* the
/// trash (`identities(inBoard:)`), so no folder of that name can already be in there the
/// import boundary reminted any arriving twin before it ever landed. Should one exist regardless
/// (a hand copy, an interrupted move), the move fails loudly through `FileManager` rather than
/// clobbering it: this call never remints, because the identity is exactly what a later restore
/// and the undo stack are holding on to.
///
/// - Returns: the card's identity, unchanged a delete moves a folder, it does not rename one.
@discardableResult
public static func deleteCardToTrash(
at cardFolder: URL,
inBoard boardRoot: URL,
order: Double
) throws(BoardWriteError) -> ItemID {
try moveCardIntoTrash(
at: cardFolder,
inBoard: boardRoot,
order: order,
operation: .delete(title: nil),
removingLegacyKey: false
)
}
/// **Migrating a legacy tombstoned card**: the same physical move into `.trash/`, plus the
/// surgical removal of the `deleted:` key that put it there (01-storage-format.md § Deletion:
/// "a card carrying `deleted:` is relocated into `.trash/` (key removed)").
///
/// `deleteCardToTrash` with one extra edit, and written as such rather than as a parameter on
/// the public delete: the two are different events with different vocabulary one is the user
/// pressing , the other is the app tidying a board written by an older version and a
/// failure must say which (`WriteOperation.migrateTombstone`).
///
/// The removal is `FrontmatterDocument.remove`, so it takes **every** occurrence of the key:
/// a hand-duplicated `deleted:` line cannot leave a twin behind that would re-migrate the card
/// on the next load. Nothing else in the file is touched unknown keys, comments, blank lines,
/// line endings and the body are the same bytes they were, and `order` and the stamps are the
/// only writes, exactly as for an ordinary delete.
@discardableResult
public static func migrateTombstonedCard(
at cardFolder: URL,
inBoard boardRoot: URL,
order: Double
) throws(BoardWriteError) -> ItemID {
try moveCardIntoTrash(
at: cardFolder,
inBoard: boardRoot,
order: order,
operation: .migrateTombstone(title: nil),
removingLegacyKey: true
)
}
/// The shared body of `deleteCardToTrash` and `migrateTombstonedCard` see the former for the
/// sequence and the latter for what `removingLegacyKey` adds.
private static func moveCardIntoTrash(
at cardFolder: URL,
inBoard boardRoot: URL,
order: Double,
operation initialOperation: WriteOperation,
removingLegacyKey: Bool
) throws(BoardWriteError) -> ItemID {
var operation = initialOperation
try checkIsDirectory(cardFolder, describedAs: "card folder", operation: operation)
try checkIsDirectory(boardRoot, describedAs: "board folder", operation: operation)
try checkIsCardFolder(cardFolder, operation: operation)
operation = try checkIndexIsRewritable(inItemFolder: cardFolder, operation: operation)
let trash = trashFolder(inBoard: boardRoot)
do {
try FileManager.default.createDirectory(at: trash, withIntermediateDirectories: true)
} catch {
throw BoardWriteError(
operation: operation,
path: trash.path,
reason: .io(message: "could not create the trash folder: \(error.localizedDescription)")
)
}
let name = cardFolder.lastPathComponent
let arrived = trash.appendingPathComponent(name, isDirectory: true)
do {
try FileManager.default.moveItem(at: cardFolder, to: arrived)
} catch {
throw BoardWriteError(
operation: operation,
path: cardFolder.path,
reason: .io(message: "could not move folder into the trash: \(error.localizedDescription)")
)
}
try updateIndex(inItemFolder: arrived, operation: operation) { document in
document.set(FrontmatterKeys.order, to: .double(order))
if removingLegacyKey {
document.remove(FrontmatterKeys.deleted)
}
}
return ItemID(rawValue: name)
}
/// **Migrating a legacy tombstoned lane**: the `deleted:` key is removed and the lane returns
/// **live**, exactly where it always was (01-storage-format.md § Deletion: "a lane carrying
/// `deleted:` returns live with the key removed and a notice resurrection is the safe
/// direction, nothing is destroyed by migration").
///
/// **Nothing moves and nothing is removed.** There is no lane trash to move it into, and
/// destroying a lane the user may never have meant to lose is the one direction migration is
/// forbidden to take. Its cards come back with it; any of *them* carrying their own
/// `deleted:` key migrate on their own account, as ordinary tombstoned cards.
///
/// The lane's rank is untouched, so it returns to its own position among its siblings
/// position-perfect for the same reason the retired Put Back was: the folder never moved.
///
/// Refuses anything that is not a lane (`checkIsLaneFolder`): a card's migration is a move and
/// has its own call, and pointing this at one would strip the key while leaving the card
/// exactly where the tombstone had hidden it.
public static func migrateTombstonedLane(at laneFolder: URL) throws(BoardWriteError) {
let operation = WriteOperation.migrateTombstone(title: nil)
try checkIsDirectory(laneFolder, describedAs: "lane folder", operation: operation)
try checkIsLaneFolder(laneFolder, operation: operation)
try updateIndex(inItemFolder: laneFolder, operation: operation) { document in
document.remove(FrontmatterKeys.deleted)
}
}
/// **Deleting a lane is physical** the folder and everything under it are removed
/// (01-storage-format.md § Deletion; 03-board-ui.md § Trash: "Cards only. Lanes are never
/// trashed"). There is no lane trash and no tombstone; the recovery net is native undo
/// in-session and git history on git boards.
///
/// **Capture before you remove.** Undo restores a lane by replaying its bytes, which only
/// works if someone is holding them `captureSubtree(at:operation:)` is that primitive, and
/// the pairing is the caller's (the undo step captures, then calls this). Deliberately not
/// folded in here: a purge that always paid for a full tree read would make Empty Trash on a
/// large board slow for a recovery nothing was going to use.
///
/// **A folder that is already gone is success**, `purgeItem`'s rule and for its reason: a
/// Finder deletion converges on exactly the end state this produces, so there is nothing left
/// to distinguish.
///
/// Refuses anything that is not a lane (`checkIsLaneFolder`) a board root, a card, a stray,
/// and notably a *trash card*, whose parent is `.trash/` rather than the board root.
public static func removeLane(at laneFolder: URL) throws(BoardWriteError) {
let operation = WriteOperation.delete(title: nil)
guard FileManager.default.fileExists(atPath: laneFolder.path) else { return }
try checkIsLaneFolder(laneFolder, operation: operation)
do {
try FileManager.default.removeItem(at: laneFolder)
} catch {
throw BoardWriteError(
operation: operation,
path: laneFolder.path,
reason: .io(message: "could not remove folder: \(error.localizedDescription)")
)
}
}
/// Permanently removes one card from the trash the trash's **Delete / Delete Immediately**
/// (03-board-ui.md § Trash: "on a trash card, Delete (/) is permanent").
///
/// `purgeItem` with the container checked: the folder must actually sit in this board's
/// `.trash/`, so a mis-aimed permanent delete cannot reach a live card. Delete Immediately
/// *from* the board which skips the trash is `purgeItem`, not this call.
///
/// An already-gone folder is success, `purgeItem`'s rule.
public static func purgeTrashCard(at cardFolder: URL, inBoard boardRoot: URL) throws(BoardWriteError) {
let operation = WriteOperation.purge(title: nil)
guard FileManager.default.fileExists(atPath: cardFolder.path) else { return }
try checkIsUUIDShaped(cardFolder, operation: operation)
guard isSameLocation(cardFolder.deletingLastPathComponent(), trashFolder(inBoard: boardRoot)) else {
throw BoardWriteError(
operation: operation,
path: cardFolder.path,
reason: .unreadable(message: "folder is not in this board's trash")
)
}
do {
try FileManager.default.removeItem(at: cardFolder)
} catch {
throw BoardWriteError(
operation: operation,
path: cardFolder.path,
reason: .io(message: "could not remove folder: \(error.localizedDescription)")
)
}
}
/// **Empty Trash** (, 03-board-ui.md § Trash): permanently removes every card in
/// `<board-root>/.trash/`. Returns what it removed, in folder-name order.
///
/// **The card folders, not the container.** The design says it "purges the whole `.trash/`",
/// and the cards are the whole of it in every board the app produces but the container is a
/// real folder a hand-editor can put things in, and stray tolerance ("preserved verbatim,
/// never rendered") does not stop applying because the folder is the app's. Removing only what
/// the loader recognizes as a card keeps the count honest (the confirmation names cards) and
/// keeps this command from being the one place in the app that destroys a file nobody ever
/// saw. The emptied container is left standing; the next delete would only recreate it.
///
/// **Search-independent**, by construction: this walks the folder, never a filtered view.
///
/// Removal is per card, in order, and a failure stops the batch and throws everything
/// already removed stays removed, `importAttachments`' rule. A board with no trash at all
/// removes nothing and returns `[]`.
@discardableResult
public static func emptyTrash(inBoard boardRoot: URL) throws(BoardWriteError) -> [ItemID] {
let operation = WriteOperation.purge(title: nil)
var purged: [ItemID] = []
for card in childCandidates(of: trashFolder(inBoard: boardRoot)) {
do {
try FileManager.default.removeItem(at: card)
} catch {
throw BoardWriteError(
operation: operation,
path: card.path,
reason: .io(message: "could not remove folder: \(error.localizedDescription)")
)
}
purged.append(ItemID(rawValue: card.lastPathComponent))
}
return purged
}
/// Refuses any folder that is not a **lane**: UUID-shaped, directly under a board root.
///
/// The mirror of `checkIsCardFolder`, and it needs one clause that one does not. A lane is
/// `<root>/<lane>` and a card is `<root>/<lane>/<card>`, so "parent is not UUID-shaped" tells
/// the two apart except that a **trash card** is `<root>/.trash/<card>`, whose parent is not
/// UUID-shaped either. Naming the container explicitly is what keeps a permanent delete of a
/// trashed card from being reachable through the lane-delete door.
private static func checkIsLaneFolder(_ folder: URL, operation: WriteOperation) throws(BoardWriteError) {
try checkIsUUIDShaped(folder, operation: operation)
let parentName = folder.deletingLastPathComponent().lastPathComponent
guard !BoardLoader.isUUIDShaped(parentName), parentName != BoardLoader.trashFolderName else {
throw BoardWriteError(
operation: operation,
path: folder.path,
reason: .unreadable(message: "folder is not a lane: only a lane is deleted whole")
)
}
}
// MARK: - Subtree capture and replay
/// Every byte of a folder tree, in memory the capture half of a physical removal's undo
/// (13-native-undo.md Rules; 03-board-ui.md § Trash, "the net is undo, not the trash").
///
/// **`readIndexText(ofItem:operation:)` widened from one file to a whole tree**, and for the
/// same reason: the inverse of a physical removal is a recreation, and the only way Z can put
/// a lane back *with its identity and its cards* is for the step to be holding what was there.
/// One `index.md` is enough to replay a create; a lane delete takes its nested cards, their
/// `attachments/`, and every stray with it.
///
/// **Everything, verbatim.** Unlike every other walk in this file, this one does *not* apply
/// the loader's stray exclusions: hidden files (`.DS_Store`, a dot-file a hand-editor left),
/// non-UUID folders, files with no meaning to the schema all captured, because the promise is
/// that undo restores what was removed rather than what the app would have rendered. Bytes are
/// carried as `Data` and never decoded, so encoding, line endings and BOMs are non-questions;
/// POSIX permissions ride along per entry.
///
/// **Symlinks are captured as links, never followed** (01-storage-format.md § Fractal layout
/// Rules) the destination string is recorded and recreated as a link, so a cyclic or
/// cross-volume link is neither traversed here nor materialized as a copy of its target.
///
/// Entries are sorted by name at every level, so a capture is a deterministic value: two
/// captures of one unchanged tree are `==`, which is what makes a round-trip assertable.
///
/// It reads the whole tree into memory, so it is for the sizes a board actually has (a lane
/// and its cards) not a general-purpose archiver.
public static func captureSubtree(
at folder: URL,
operation: WriteOperation
) throws(BoardWriteError) -> SubtreeSnapshot {
try checkIsDirectory(folder, describedAs: "item folder", operation: operation)
let entries: [URL]
do {
entries = try FileManager.default.contentsOfDirectory(
at: folder,
includingPropertiesForKeys: [.isDirectoryKey, .isSymbolicLinkKey],
options: []
)
} catch {
throw BoardWriteError(
operation: operation,
path: folder.path,
reason: .unreadable(message: "could not list folder: \(error.localizedDescription)")
)
}
var captured: [SubtreeSnapshot.Entry] = []
for entry in entries.sorted(by: { $0.lastPathComponent < $1.lastPathComponent }) {
let name = entry.lastPathComponent
let values = try? entry.resourceValues(forKeys: [.isDirectoryKey, .isSymbolicLinkKey])
if values?.isSymbolicLink == true {
guard let destination = try? FileManager.default.destinationOfSymbolicLink(atPath: entry.path) else {
throw BoardWriteError(
operation: operation,
path: entry.path,
reason: .unreadable(message: "could not read symbolic link")
)
}
captured.append(.symlink(name: name, destination: destination))
} else if values?.isDirectory == true {
captured.append(.folder(try captureSubtree(at: entry, operation: operation)))
} else {
let contents: Data
do {
contents = try Data(contentsOf: entry)
} catch {
throw BoardWriteError(
operation: operation,
path: entry.path,
reason: .unreadable(message: "could not read file: \(error.localizedDescription)")
)
}
captured.append(.file(name: name, contents: contents, permissions: posixPermissions(of: entry)))
}
}
return SubtreeSnapshot(
name: folder.lastPathComponent,
permissions: posixPermissions(of: folder),
entries: captured
)
}
/// Puts a captured tree back, at `folder`, byte for byte the replay half of
/// `captureSubtree(at:operation:)`, and `recreateItem`'s rules one level of nesting wider.
///
/// - **The parent must already exist** (`withIntermediateDirectories: false`): a redo whose
/// board root has since gone must fail rather than conjure a tree in mid-air.
/// - **It refuses to clobber**: anything at `folder` fails loudly rather than being written
/// over. Restoring on top of a folder someone recreated meanwhile would silently merge two
/// trees.
/// - **The bytes are written verbatim** nothing is stamped, nothing is re-serialized, no
/// `index.md` is parsed. This replays; it does not edit.
/// - **All-or-nothing**: any failure removes the partial tree best-effort and rethrows, the
/// `materializeItem` rule a half-restored lane is pure residue, since nothing was there.
///
/// `folder`'s own name governs, not `snapshot.name`: a caller restoring to the path it removed
/// passes the same URL, and the snapshot's name is carried for identification, not as an
/// instruction.
///
/// Permissions are applied **after** a folder's children are written, so a captured read-only
/// directory does not lock out its own contents on the way back in.
public static func recreateSubtree(
at folder: URL,
from snapshot: SubtreeSnapshot,
operation: WriteOperation
) throws(BoardWriteError) {
guard !FileManager.default.fileExists(atPath: folder.path) else {
throw BoardWriteError(
operation: operation,
path: folder.path,
reason: .io(message: "something already exists here")
)
}
do throws(BoardWriteError) {
try materialize(snapshot, at: folder, operation: operation, intermediates: false)
} catch {
try? FileManager.default.removeItem(at: folder)
throw error
}
}
/// `recreateSubtree`'s recursion, minus its clobber refusal and its cleanup both belong to
/// the top-level call, which is the only one with a partial tree to remove.
private static func materialize(
_ snapshot: SubtreeSnapshot,
at folder: URL,
operation: WriteOperation,
intermediates: Bool
) throws(BoardWriteError) {
do {
try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: intermediates)
} catch {
throw BoardWriteError(
operation: operation,
path: folder.path,
reason: .io(message: "could not create folder: \(error.localizedDescription)")
)
}
for entry in snapshot.entries {
switch entry {
case let .file(name, contents, permissions):
let fileURL = folder.appendingPathComponent(name)
do {
try contents.write(to: fileURL)
} catch {
throw BoardWriteError(
operation: operation,
path: fileURL.path,
reason: .io(message: "could not write file: \(error.localizedDescription)")
)
}
setPosixPermissions(permissions, of: fileURL)
case let .folder(child):
try materialize(
child,
at: folder.appendingPathComponent(child.name, isDirectory: true),
operation: operation,
intermediates: false
)
case let .symlink(name, destination):
let linkURL = folder.appendingPathComponent(name)
do {
try FileManager.default.createSymbolicLink(atPath: linkURL.path, withDestinationPath: destination)
} catch {
throw BoardWriteError(
operation: operation,
path: linkURL.path,
reason: .io(message: "could not create symbolic link: \(error.localizedDescription)")
)
}
}
}
// After the children, so a captured read-only folder cannot lock out its own contents.
setPosixPermissions(snapshot.permissions, of: folder)
}
/// An item's POSIX permission bits, or `nil` when they cannot be read `attributesOfItem`
/// rather than a `URLResourceValues` key because it is `lstat`-based, so a symlink's own
/// attributes are never its target's. Best-effort by design: permissions decorate a capture,
/// and a tree that restores with default modes is a far better outcome than one that refuses
/// to restore.
private static func posixPermissions(of url: URL) -> Int? {
(try? FileManager.default.attributesOfItem(atPath: url.path))?[.posixPermissions] as? Int
}
private static func setPosixPermissions(_ permissions: Int?, of url: URL) {
guard let permissions else { return }
try? FileManager.default.setAttributes([.posixPermissions: permissions], ofItemAtPath: url.path)
}
// MARK: - Tombstone (retiring)
//
// The tombstone model is retired (01-storage-format.md § Deletion, resettled 2026-07-28): the
// app's delete is the physical move above, and no `deleted:` key is ever written again. The
// three calls below are kept only while their callers are still being moved across the
// migration removes the last keys any of them could act on, and they go with the last consumer.
/// Tombstones a lane or card in place: writes `deleted: <now>` into its own `index.md`
/// the whole of a delete (01-storage-format.md § Deletion). The folder never moves, never
@@ -1815,6 +2314,46 @@ public struct MoveResult: Sendable, Equatable {
}
}
// MARK: - Subtree vocabulary
/// A folder tree captured whole, in memory what `BoardWriter.captureSubtree(at:operation:)`
/// produces and `recreateSubtree(at:from:operation:)` replays.
///
/// **A value, deliberately**: `Sendable` so an undo step can carry it across isolation domains,
/// and `Equatable` so a capture recreate capture round trip is one assertion. Equality is
/// exact names, bytes, link destinations, permissions, and order which holds because a capture
/// sorts every level by name.
///
/// It describes bytes, never meaning. There is no `index.md` here, no frontmatter, no identity:
/// a lane, a card, an `attachments/` folder and a hand-made `notes/` are all just folders with
/// entries, which is exactly what a byte-faithful restore needs and all it may assume.
public struct SubtreeSnapshot: Sendable, Equatable {
/// The captured folder's own name. Carried for identification and for nested folders' paths;
/// the top-level replay takes its path from the caller instead (see `recreateSubtree`).
public let name: String
/// POSIX permission bits as captured, `nil` when unreadable applied best-effort on replay.
public let permissions: Int?
/// The folder's direct children, sorted by name.
public let entries: [Entry]
public init(name: String, permissions: Int?, entries: [Entry]) {
self.name = name
self.permissions = permissions
self.entries = entries
}
/// One captured child. Symlinks are their own case rather than a file holding their target's
/// bytes "symlinks are never traversed" (01-storage-format.md § Fractal layout Rules), so
/// a capture records the link and a replay recreates the link.
public enum Entry: Sendable, Equatable {
case file(name: String, contents: Data, permissions: Int?)
case folder(SubtreeSnapshot)
case symlink(name: String, destination: String)
}
}
/// How a copy stamps the files it materializes the one axis on which the two kinds of copy
/// differ (01-storage-format.md § Fractal layout Rules; § Frontmatter).
public enum CopyStamps: Sendable {
@@ -1854,9 +2393,23 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible {
case move(title: String?)
case reorder(title: String?)
case copy(title: String?)
case delete(title: String?) // tombstone
/// / a card moving into `.trash/` (`deleteCardToTrash`) or a lane being removed
/// outright (`removeLane`). Still the word the user pressed; the retiring tombstone write
/// shares it while it lasts.
case delete(title: String?)
case restore(title: String?)
case purge(title: String?)
/// A legacy `deleted:` key being migrated away a card relocating into `.trash/` with the key
/// removed, or a lane getting the key stripped and returning live (01-storage-format.md
/// § Deletion, "Legacy `deleted:` keys migrate on load-and-write, never destroy").
///
/// Its own case rather than a fold into `.delete` or `.move`, on the vocabulary's standing
/// reasoning and `.relocateLooseFile`'s in particular: this is work the *app* started on its
/// own, on a board an older version wrote, and a banner telling the user the app "couldn't
/// delete 'Fix login'" would name a gesture they never made on a lane, one whose outcome is
/// the opposite of deletion.
case migrateTombstone(title: String?)
case style(title: String?) // updateIndex on behalf of styling flows (03-board-ui.md)
case resize(title: String?) // a lane's `width` the edge drag and the stepper alike (03-board-ui.md § Lane)
/// An inline title editor's commit the third inline editor's write (04-interactions.md
@@ -1954,6 +2507,7 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible {
case .delete: .delete(title: title)
case .restore: .restore(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)
@@ -1981,6 +2535,7 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible {
case let .delete(title): Self.phrase("delete", title)
case let .restore(title): Self.phrase("restore", 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)