Register inverse operations at the Writer boundary

The store is the Writer boundary, so it computes and registers
inverses: a weak history sink bound at session composition, one
HistoryStep per gesture at exactly the brackets that were already one
performWrite each — multi-card moves, style batches, width pairs, and
multi-row restores each undo as one plurally-titled step, and the Edit
session registers once at the flip from the bytes disk held before its
first landed write, debounce ticks registering nothing. Crossings run
through performWrite, so an undo brackets the watcher, echoes through
the reload, and reaches every window; every closure captures values,
never snapshots. The inventory follows 13 exactly: moves return to
origin lane and order, renames restore or remove the title key,
restyles and resizes restore field values or absence, tombstones and
restores swap with captured timestamps, and an undone create is a real
removal — no trace — with redo re-materializing the same UUID from
bytes captured at gesture time. Purge, attachments, repair,
bookkeeping, checkbox flips, raw Apply, and the whole arrival family
register nothing, each exclusion documented where it lives. Step names
speak 06's verb vocabulary through the new HistoryPhrase.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
2026-07-28 14:24:31 -04:00
parent 93fad2ef1e
commit 2148ebb379
8 changed files with 1732 additions and 64 deletions
+90
View File
@@ -979,6 +979,96 @@ public enum BoardWriter: Sendable {
}
}
// MARK: - Undoing a create
/// An item's `index.md` as literal text the bytes a create hands to its own redo
/// (13-native-undo.md Rules: "create remove the created folder").
///
/// **Why the create path reads back what it just wrote.** The inverse of a create is a physical
/// removal, so the only way Z can put the item back *with its identity* is for the step to be
/// holding the file's bytes captured at the moment of the create, which is the write redo
/// re-performs (13: the redo closure replays the original). `readRawSource(ofCard:)` is the same
/// read one level narrower it is the raw-source outlet's, and refuses anything that is not a
/// card folder so this one exists rather than widening that contract for a caller with a
/// different reason.
///
/// Strict UTF-8 like every read here: a file that does not decode is a loud error, never a lossy
/// guess. `operation` is the create being captured for, so a failure names that gesture.
public static func readIndexText(
ofItem itemFolder: URL,
operation: WriteOperation
) throws(BoardWriteError) -> String {
let indexURL = itemFolder.appendingPathComponent(BoardLoader.indexFileName)
let data: Data
do {
data = try Data(contentsOf: indexURL)
} catch {
throw BoardWriteError(
operation: operation,
path: indexURL.path,
reason: .unreadable(message: "could not read file: \(error.localizedDescription)")
)
}
guard let text = String(validating: data, as: UTF8.self) else {
throw BoardWriteError(operation: operation, path: indexURL.path, reason: .unreadable(message: "file is not UTF-8"))
}
return text
}
/// Puts a removed item's folder back, at its own path and with its own bytes the redo half of
/// an undone create (13-native-undo.md Rules).
///
/// **The identity is the point.** `createLane`/`createCard` mint a fresh UUID and
/// `materializeItem` mints a fresh one too, so neither can replay a create: redoing through them
/// would produce a *different* item, and every step registered above this one on the stack
/// (a rename, a move, a body edit of that very card) would then name nothing. This call takes the
/// path as given.
///
/// - **The parent must already exist** (`withIntermediateDirectories: false`): a redo whose lane
/// has since been purged must fail rather than conjure a bare lane folder around the card.
/// - **It refuses to clobber**, `createBoard`'s rule: a folder already at this path fails loudly
/// rather than overwriting whatever is living there now.
/// - **The bytes are written verbatim**, raw-source Apply's rule and for its reason: they are not
/// composed here, they are replayed, so nothing is stamped and nothing is re-serialized.
///
/// `operation` is the caller's own vocabulary word `.createLane` or `.createCard`, whichever
/// create is being replayed so a failure banners as the gesture the user is redoing.
public static func recreateItem(
at itemFolder: URL,
indexText: String,
operation: WriteOperation
) throws(BoardWriteError) {
try checkIsUUIDShaped(itemFolder, operation: operation)
guard !FileManager.default.fileExists(atPath: itemFolder.path) else {
throw BoardWriteError(
operation: operation,
path: itemFolder.path,
reason: .io(message: "something already exists here")
)
}
do {
try FileManager.default.createDirectory(at: itemFolder, withIntermediateDirectories: false)
} catch {
throw BoardWriteError(
operation: operation,
path: itemFolder.path,
reason: .io(message: "could not create folder: \(error.localizedDescription)")
)
}
do throws(BoardWriteError) {
try atomicReplace(
text: indexText,
at: itemFolder.appendingPathComponent(BoardLoader.indexFileName),
operation: operation
)
} catch {
// `materializeItem`'s all-or-nothing rule: a half-made folder is pure residue, since
// nothing was there before.
try? FileManager.default.removeItem(at: itemFolder)
throw error
}
}
// MARK: - Task checkboxes
/// Flips one task-list checkbox in an item's body **a single-byte edit, and the only write