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
+192 -23
View File
@@ -1,4 +1,5 @@
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
@@ -133,14 +134,15 @@ public enum BoardWriter: Sendable {
/// 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? {
switch IntegrityRules.placement(
ofFolderNamed: folder.lastPathComponent,
inParentNamed: folder.deletingLastPathComponent().lastPathComponent
) {
// 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.
@@ -386,7 +388,9 @@ public enum BoardWriter: Sendable {
/// 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.
private static func freshUUIDName(in parentFolder: URL, avoiding taken: Set<String>) -> String {
/// 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>) -> String {
var name: String
repeat {
name = UUID().uuidString.lowercased()
@@ -401,7 +405,9 @@ public enum BoardWriter: Sendable {
/// 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.
private static func checkIsDirectory(
/// **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
@@ -746,14 +752,19 @@ public enum BoardWriter: Sendable {
/// 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.
private static func childCandidates(of folder: URL) -> [URL] {
/// 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.
private static func renameFolder(
///
/// 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
@@ -777,7 +788,8 @@ public enum BoardWriter: Sendable {
/// 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.
private static func isSameLocation(_ lhs: URL, _ rhs: URL) -> Bool {
/// 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
}
@@ -867,6 +879,12 @@ public enum BoardWriter: Sendable {
}
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)
@@ -903,6 +921,16 @@ public enum BoardWriter: Sendable {
/// 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],
@@ -916,6 +944,44 @@ public enum BoardWriter: Sendable {
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
@@ -968,10 +1034,17 @@ public enum BoardWriter: Sendable {
/// 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. The
/// walk is `identityDescendants`', which is `remintDescendants`' own reach so the set checked
/// here is exactly the set that will be stamped, never a superset that could refuse a copy over a
/// file nobody was going to touch.
/// 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.
@@ -987,9 +1060,11 @@ public enum BoardWriter: Sendable {
}
}
/// Every identity-bearing folder beneath `folder`, depth first `remintDescendants`' walk with
/// the renaming taken out, so the preflight and the remint can never disagree about which folders
/// a copy materializes as items.
/// 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) {
@@ -1023,11 +1098,37 @@ public enum BoardWriter: Sendable {
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
/// `<boardRoot>/.trash/` the board's trash container, named but not created.
@@ -1185,8 +1286,12 @@ public enum BoardWriter: Sendable {
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: try checkIsCardFolder(itemFolder, operation: operation)
case .card, .board, .comment: try checkIsCardFolder(itemFolder, operation: operation)
}
operation = try checkIndexIsRewritable(inItemFolder: itemFolder, operation: operation)
@@ -1949,7 +2054,9 @@ public enum BoardWriter: Sendable {
/// fixes the depth: a card is `<root>/<lane>/<card>` and a lane is `<root>/<lane>`, 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.
private static func checkIsCardFolder(_ folder: URL, operation: WriteOperation) throws(BoardWriteError) {
/// 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(
@@ -2287,7 +2394,8 @@ public enum BoardWriter: Sendable {
/// 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.
private static func checkIndexIsRewritable(
/// 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 {
@@ -2306,7 +2414,9 @@ public enum BoardWriter: Sendable {
/// 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).
private static func readDocument(at url: URL, operation: WriteOperation) throws(BoardWriteError) -> FrontmatterDocument {
/// 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)
@@ -2329,7 +2439,8 @@ public enum BoardWriter: Sendable {
}
}
private static func checkEditable(
/// Internal rather than `private`, with `readDocument` and for its reason.
static func checkEditable(
_ document: FrontmatterDocument,
at url: URL,
operation: WriteOperation
@@ -2565,6 +2676,49 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible {
/// 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
@@ -2581,9 +2735,13 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible {
// `.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:
.displaceClaimedName, .repairDuplicateID, .saveCommentDraft, .postComment,
.editComment, .deleteComment, .purgeCommentTrash:
self
case .move: .move(title: title)
case .reorder: .reorder(title: title)
@@ -2638,10 +2796,15 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible {
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:
.repairDuplicateID, .toggleTask, .editBody, .rawSource, .saveCommentDraft, .postComment,
.editComment, .deleteComment, .purgeCommentTrash:
false
}
}
@@ -2680,6 +2843,12 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible {
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"
}
}