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
+244
View File
@@ -0,0 +1,244 @@
import Foundation
// MARK: - The comment gestures
/// **The comment thread at the store boundary** every comment write the app makes, bracketed, with
/// its inverse registered where 13-native-undo.md says one belongs.
///
/// An extension in its own file for `BoardStoreHistory.swift`'s reason, and with its reason for
/// living on `BoardStore` at all: comments are read by the *card window* and written through the
/// *board's* store, because "one stack per board, owned by the board session. Not per-window: every
/// window over a board (board window, its card windows) shares the store and shares the stack"
/// (13 Rules). A comment posted in a card window is undone by Z in the board window, which is only
/// true if the step was registered here.
///
/// ### What registers a step, and what deliberately does not
///
/// Two of the five (13 Interaction with the trash, the comment clause; 13 Rules):
///
/// - **`postComment`** inverse: the rename back to `.draft`. Move-based, no capture.
/// - **`deleteComment`** inverse: the move back out of `comments/.trash/`. Move-based, no capture.
/// - **`saveCommentDraft` and `editComment` register nothing**, and that is the no-capture rule
/// rather than a deferral: undoing a body edit means holding the prior bytes, which 13 forbids in
/// every tier. An inline edit's revert is its *session*'s (Cancel/Escape reverts to session-start
/// bytes 05-card-window.md), which is a live buffer, not a stack entry.
/// - **`purgeCommentTrash` registers nothing** the permanent-delete posture. It is also what makes
/// the interaction with the stack correct for free: leftover comment steps go stale after a purge
/// and skip with the ordinary info-tone banner, because the folders their expectations name are
/// gone. **The expectations validate disk, never the snapshot** (`HistoryStaleness`), which is
/// exactly why this works comments are not in the snapshot at all.
@MainActor
extension BoardStore {
// MARK: Reading
/// One card's thread, read fresh from disk **window-scoped**, never cached on the store and
/// never part of `snapshot` (01-storage-format.md § Enhanced schema: "the board snapshot never
/// loads comment content", so the board walk stays O(cards)).
///
/// `.empty` for an id that names no card on the board the same vanished-target answer every
/// other card-scoped call gives (`boardItem`).
public func commentThread(inCard id: ItemID) -> CommentThread {
guard let card = commentSubject(id) else { return .empty }
return CommentThread.load(inCard: card.folder, path: card.path)
}
// MARK: The draft
/// Saves the composer's draft one bracket, no step.
///
/// - Returns: what landed, or `nil` when the write failed (the banner is already
/// `performWrite`'s) or the card is gone.
@discardableResult
public func saveCommentDraft(inCard id: ItemID, body: String) -> CommentDraftOutcome? {
guard let card = commentSubject(id) else { return nil }
return try? performWrite { () throws(BoardWriteError) -> CommentDraftOutcome in
try BoardWriter.saveCommentDraft(inCard: card.folder, body: body, cardTitle: card.title)
}
}
/// **Posts the draft, and registers the one step the gesture owes** ( or the Comment button).
///
/// The step's predicate is the brief the ruling gives it: the undo needs the posted folder still
/// at its path **and `.draft` still absent** a draft the user has started typing since is not
/// this step's to overwrite and the redo needs the mirror. Both are `HistoryExpectation`s over
/// disk, and the `.absent` half is the same one an undone create uses.
///
/// **The redo replays the captured identity and the captured instant**, never fresh ones: a redo
/// that re-minted would post a *different* comment, and any step registered above this one naming
/// the posted id would name nothing.
@discardableResult
public func postComment(inCard id: ItemID) -> ItemID? {
guard let card = commentSubject(id) else { return nil }
guard let posted = try? performWrite({ () throws(BoardWriteError) -> PostedComment in
try BoardWriter.postComment(inCard: card.folder, cardTitle: card.title)
}) else {
return nil
}
let folder = card.folder
let title = card.title
let draftFolder = CommentThread.draftFolder(inCard: folder)
let postedFolder = CommentThread.commentFolder(posted.id, inCard: folder)
registerStep(
HistoryPhrase.comment,
subject: title,
undoExpects: [.present(postedFolder), .absent(draftFolder)],
redoExpects: [.present(draftFolder), .absent(postedFolder)]
) { _ in
try BoardWriter.unpostComment(posted.id, inCard: folder, cardTitle: title)
} redo: { _ in
try BoardWriter.repostComment(
as: posted.id,
inCard: folder,
stamping: posted.posted,
cardTitle: title
)
}
return posted.id
}
// MARK: Editing
/// An inline edit session's save one bracket, **no step** (13's no-capture rule; the session
/// owns Cancel).
///
/// - Returns: whether bytes were written; `false` also for a card or comment that is gone.
@discardableResult
public func editComment(_ commentID: ItemID, inCard id: ItemID, body: String) -> Bool {
guard let card = commentSubject(id) else { return false }
let folder = CommentThread.commentFolder(commentID, inCard: card.folder)
let wrote = try? performWrite { () throws(BoardWriteError) -> Bool in
try BoardWriter.editComment(at: folder, body: body, cardTitle: card.title)
}
return wrote ?? false
}
// MARK: Delete and its inverse
/// **Deletes a comment a move into `comments/.trash/`, immediate, no confirm, undoable**
/// (01-storage-format.md § Enhanced schema; 05-card-window.md The comments column).
///
/// The step is the move read backwards, `moveToTrash`'s registration one level down and without
/// its rank half: a comment's trash has no order, so there is no `.order` after-value to compare
/// and existence is the whole predicate. The container rides in the path exactly as it does at
/// board level the undo expects the comment in `comments/.trash/`, the redo expects it back in
/// the thread so a foreign restore or a foreign re-delete skips the right half by itself.
@discardableResult
public func deleteComment(_ commentID: ItemID, inCard id: ItemID) -> Bool {
guard let card = commentSubject(id) else { return false }
let folder = card.folder
let title = card.title
let live = CommentThread.commentFolder(commentID, inCard: folder)
let landed: Void? = try? performWrite { () throws(BoardWriteError) -> Void in
_ = try BoardWriter.deleteComment(at: live, cardTitle: title)
}
guard landed != nil else { return false }
let trashed = CommentThread.trashedCommentFolder(commentID, inCard: folder)
registerStep(
HistoryPhrase.name(.delete, kind: .comment),
subject: title,
undoExpects: [.present(trashed)],
redoExpects: [.present(live)]
) { _ in
try BoardWriter.restoreComment(commentID, inCard: folder, cardTitle: title)
} redo: { _ in
_ = try BoardWriter.deleteComment(at: live, cardTitle: title)
}
return true
}
// MARK: The purge, and the crash residue it leaves
/// **Empties one card's `comments/.trash/`** the card window's close flush (01-storage-format.md
/// § Enhanced schema: "purged when the card window closes (rides the close flush)").
///
/// One bracket, no step. Leftover comment steps on the board's stack are not pruned here and must
/// not be: invalidation is lazy (13 Rules), so they stay on the stack, look full, and skip with
/// the ordinary info-tone banner the first time one is crossed.
public func purgeCommentTrash(inCard id: ItemID) {
guard let card = commentSubject(id) else { return }
try? performWrite { () throws(BoardWriteError) -> Void in
_ = try BoardWriter.purgeCommentTrash(inCard: card.folder)
}
}
/// **The crash-residue sweep**, run when a card window opens (§ Enhanced schema: "crash residue
/// sweeps at the next card-window open, armed-then-cleared like every heal memo").
///
/// The same six steps every scheduled heal gets, through the same engine: **rest** when the trash
/// is empty (which is every open on a board that closed cleanly, and costs no bracket at all),
/// defer under a read-only lock, compare the signature, arm before attempting, one bracket, clear
/// on success.
///
/// **Silent** `HealNotice.none`. `comments/.trash/` is "never a UI surface", and the residue is
/// the app's own leftovers from a session that died; there is nothing here a user could act on.
///
/// The memo is board-wide and keyed by class, so two cards' residue swept in turn re-arm each
/// other's picture. That is harmless rather than tolerated: the picture *is* the work, the write
/// half re-verifies against disk, and a sweep with nothing to remove is a no-op.
public func sweepCommentTrashResidue(inCard id: ItemID) {
guard let card = commentSubject(id) else { return }
let folder = card.folder
let residue = CommentThread.trashedCommentIDs(inCard: folder)
heals.run(
.commentTrashResidue,
signature: Set(residue.map { "comment-trash:\(card.path)/\($0.rawValue)" }),
on: self
) { () throws(BoardWriteError) -> Void in
_ = try BoardWriter.purgeCommentTrash(inCard: folder)
}
}
// MARK: The thread's claimed names
/// Displaces the claimed names one thread read found squatted `comments/.draft`,
/// `comments/.trash`, and a comment's own `attachments` (01-storage-format.md § Fractal layout
/// Rules, the level-uniform displacement, applied at the two levels the thread adds).
///
/// **One bracket over the batch, and no memo**, which is the one place this heal differs from its
/// board-level twin (`displaceClaimedNames`) deliberately, and worth stating:
/// `HealScheduler`'s memo is keyed by defect *class*, and these squatters are the same class as
/// the board walk's. Sharing the key would have one card's thread picture overwrite the board's
/// and back again on every reload, so the two would spend their memos fighting instead of guarding.
/// What replaces the memo here is the trigger: a thread is re-read when its window opens or its
/// files change, not on a timer, so there is no hot loop for a memo to break.
///
/// - Parameter squatters: the defects a `CommentThread` read reported (its `defects`, filtered).
/// - Returns: what was actually moved aside, for the caller's warning-tone notice the Writer
/// re-verifies each against disk and answers `nil` for one that freed itself.
@discardableResult
public func displaceCommentClaimedNames(_ squatters: [ClaimedNameSquatter]) -> [BannerCenter.Displacement] {
guard !squatters.isEmpty else { return [] }
let root = rootURL
var displaced: [BannerCenter.Displacement] = []
try? performWrite { () throws(BoardWriteError) -> Void in
for squatter in squatters {
guard let freed = try BoardWriter.displaceClaimedName(squatter, atBoardRoot: root) else {
continue
}
displaced.append(BannerCenter.Displacement(name: squatter.name, movedTo: freed))
}
}
return displaced
}
// MARK: - Resolving the card
/// Where a card's thread lives and what to call the gesture the three facts every comment write
/// needs, resolved off the snapshot the way every other card-scoped call resolves them
/// (`boardItem`): `nil` for an id that names no live card, which is the vanished-target guard.
///
/// The path is rebuilt from `rootURL` rather than remembered, so a board renamed mid-session
/// writes at its new location.
private func commentSubject(_ id: ItemID) -> (folder: URL, path: String, title: String?)? {
guard let item = Self.boardItem(id, in: snapshot), let cardID = item.cardID else { return nil }
return (
folder: ItemPath.card(lane: item.laneID, id: cardID).folder(under: rootURL),
path: "\(item.laneID.rawValue)/\(cardID.rawValue)",
title: item.title
)
}
}