Implement tombstone delete, restore, and purge

deleteItem writes deleted: <now> in place through updateIndex — the
folder never moves, nothing beneath it is touched (hiding the subtree
is the renderer's ancestor walk, not a stored flag). restoreItem
removes the key — position-perfect by construction, every duplicate
occurrence taken so a hand-written twin cannot resurrect the
tombstone. purgeItem physically removes the tree; an already-missing
folder is success (a Finder deletion converges on the same end
state), and no prior tombstone is required (Delete Immediately skips
the stage by design). Board-root deletion is structurally unreachable
via the UUID-shape guard shared with move/copy. Neither delete nor
restore polices liveness — re-deleting refreshes the timestamp,
restoring a live item is a harmless stamped rewrite.

14 new unit tests; 234 total green.

Claude-Session: https://claude.ai/code/session_018BjQRYBR6jQja3jCRi5S3A
This commit is contained in:
2026-07-26 17:42:22 -04:00
parent eb9e1e413f
commit fa834501b5
2 changed files with 344 additions and 3 deletions
+88 -3
View File
@@ -732,6 +732,88 @@ public enum BoardWriter: Sendable {
}
}
// MARK: - Tombstone
/// 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
/// renames, and nothing beneath it is touched: hiding the subtree is the renderer's
/// ancestor walk, not a stored flag, so deleting a lane rewrites *only* the lane's own
/// file its cards' files are exactly as they were.
///
/// **Board-root deletion is structurally unreachable at this layer**: `checkIsUUIDShaped`
/// the same guard `moveItem`/`copyItem` lean on refuses any folder whose name isn't
/// UUID-shaped, and a board root never is (§ Board naming). A board-level `deleted:` key
/// is legal-but-meaningless per the frontmatter table (the loader ignores and warns on
/// it), but this call is simply never able to *produce* one: it has no board-root code
/// path to fall through, only a refusal.
///
/// Deleting an **already-tombstoned** item is not refused it just refreshes the
/// timestamp, a harmless rewrite (the gesture happened again; this layer does not police
/// liveness, the store's UI does). Goes through `updateIndex`, so the usual contract
/// applies: fresh read, refuse an uneditable shape, `modified` stamped and `modified-by`
/// cleared, atomic replace.
public static func deleteItem(at itemFolder: URL) throws(BoardWriteError) {
let operation = "delete item"
try checkIsDirectory(itemFolder, describedAs: "item folder", operation: operation)
try checkIsUUIDShaped(itemFolder, operation: operation)
try updateIndex(inItemFolder: itemFolder, operation: operation) { document in
document.set(FrontmatterKeys.deleted, to: .date(Date()))
}
}
/// Put Back: removes the `deleted` key, undoing exactly what `deleteItem` wrote.
/// **Position-perfect by construction** the folder never moved, so the item simply
/// re-enters the visible set at its recorded `order` among its current siblings
/// (01-storage-format.md § Deletion). `FrontmatterDocument.remove` takes *every*
/// occurrence of the key, so a hand-duplicated `deleted` line cannot resurrect the
/// tombstone the instant the winning occurrence is gone.
///
/// Restoring an item that **isn't** tombstoned is not refused it is a harmless stamped
/// rewrite, the same shrug `deleteItem` gives an already-deleted item: this layer does not
/// police liveness (a second, independent liveness check here could only drift from the
/// store UI's own, which is what actually decides whether Put Back is offered at all).
public static func restoreItem(at itemFolder: URL) throws(BoardWriteError) {
let operation = "restore item"
try checkIsDirectory(itemFolder, describedAs: "item folder", operation: operation)
try checkIsUUIDShaped(itemFolder, operation: operation)
try updateIndex(inItemFolder: itemFolder, operation: operation) { document in
document.remove(FrontmatterKeys.deleted)
}
}
/// Physical removal Delete Immediately / Empty Trash (03-board-ui.md): deletes the
/// folder tree from disk. Irreversible, and distinct from tombstoning this call does
/// **not** require the item to be tombstoned first, since Delete Immediately skips the
/// tombstone stage by design.
///
/// **A folder that is already gone is success, not an error** checked first, before the
/// shape guard below. A Finder deletion converges on exactly the end state a purge would
/// produce (01-storage-format.md § Deletion, "a folder that disappears without a
/// tombstone... 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
/// `deleteItem`/`restoreItem` 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 = "purge item"
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)")
)
}
}
// MARK: - Move/copy pre-flight
/// The rank a moved or copied root lands on: the caller's explicit value a drop between
@@ -754,14 +836,17 @@ public enum BoardWriter: Sendable {
/// 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/`, an uppercase
/// UUID, a hand-made folder is not an item, and moving or copying one as if it were would
/// invent an identity the loader would then ignore.
/// 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: String) 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 move and copy")
reason: .unreadable(message: "folder name is not UUID-shaped: only lanes and cards are valid here")
)
}
}