Comments, phase 1 — storage, writer primitives, and the undo inventory

The kind: comment field table lands in IntegrityRules (the per-kind
hook's first exercise), CommentThread reads one card's thread
window-scoped (the board walk stays O(cards)), and CommentWriter gains
the five gestures: draft save, post (rename .draft to a fresh UUID,
created/modified restamped in the bracket), edit, delete into
comments/.trash/, and the purge with its crash-residue memo. Post and
delete register move-based undo steps; draft saves, edits, and the
purge deliberately register nothing (13's no-capture rule). Copy
boundaries strip comments/.trash, carry .draft verbatim, and remint
threads; comments graduates to a displacing claimed name, with .draft,
.trash, and a comment's attachments claimed one level down.
CommentPath classifies changed paths into the 06 verb family for
later announcer/composer wiring.

One stated narrowing pending a ruling (filed on the findings board):
the copy transaction's refuse-whole preflight stays cards-and-lanes —
an unstampable copied comment copies verbatim with a log line, because
comment defects never refuse.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
2026-07-30 19:36:21 -04:00
parent e6dd4c0aa6
commit f68ac3668e
16 changed files with 2881 additions and 63 deletions
+15
View File
@@ -106,8 +106,23 @@ enum BoardTreeCopy {
throw .failed(url: source, error: error)
}
// The one exclusion this walk makes without being asked (01-storage-format.md § Enhanced
// schema: `comments/.trash/` is "**stripped at every copy boundary** (clipboard staging,
// Duplicate, Save as Template) a copy must not carry ghosts no window session will ever
// purge"). Unconditional because all three of this walk's callers *are* copy boundaries, and
// an opt-in flag would be three call sites agreeing to one rule instead of one rule.
//
// It is the one depth-sensitive exclusion, hence the parent check rather than a name in
// `excluded`: `.trash` at a board root is the board's own and carries or not per each flow's
// stated exclusions, while `.trash` inside a `comments/` is undo's backing store and never
// travels.
let isCommentThread = source.lastPathComponent.lowercased() == IntegrityRules.commentsFolderName
for entry in entries.sorted(by: { $0.lastPathComponent < $1.lastPathComponent }) {
guard !excluded.contains(entry.lastPathComponent.lowercased()) else { continue }
guard !isCommentThread
|| entry.lastPathComponent.lowercased() != IntegrityRules.commentTrashFolderName
else { continue }
// Between items, never mid-item: this is the whole of "checks cancellation between
// items", and the reason the copy is a walk at all.
+8
View File
@@ -477,6 +477,13 @@ public final class ClipboardStore {
/// makes the next paste **refuse whole**, naming it (`perform`'s preflight), rather than
/// materializing it hollow from the manifest's embedded `index.md`. Failing to stage is therefore
/// as loud as it should be, one gesture later.
/// **Staging is a copy boundary**, so `comments/.trash/` does not survive it (01-storage-format.md
/// § Enhanced schema: "stripped at every copy boundary (clipboard staging, Duplicate, Save as
/// Template)"). Stripped from the *snapshot* rather than skipped during it, because the snapshot is
/// one monolithic `copyItem` and stripped through the Writer's own call so the rule is one
/// function (`BoardWriter.stripCommentTrash`), not a second reading of it here. Best-effort like
/// the copy above it: a snapshot that could not be tidied is still a snapshot, and the paste that
/// materializes from it strips again at its own boundary.
private func stage(_ jobs: [StagingJob], into stagingDir: URL) {
enqueue { [jobs, stagingDir] in
guard (try? FileManager.default.createDirectory(
@@ -485,6 +492,7 @@ public final class ClipboardStore {
)) != nil else { return }
for job in jobs {
try? FileManager.default.copyItem(at: job.source, to: job.destination)
try? BoardWriter.stripCommentTrash(under: job.destination, operation: .copy(title: nil))
}
}
}