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))
}
}
}
+17
View File
@@ -64,12 +64,17 @@ public enum HistoryPhrase {
case card
case lane
case board
/// One comment. Only `.delete` reaches it: posting has its own phrase (`comment`, below), and
/// the draft save, the inline edit and the trash purge register no step at all
/// (13-native-undo.md no byte capture in any tier, and the permanent-delete posture).
case comment
var singular: String {
switch self {
case .card: "Card"
case .lane: "Lane"
case .board: "Board"
case .comment: "Comment"
}
}
@@ -78,10 +83,22 @@ public enum HistoryPhrase {
case .card: "Cards"
case .lane: "Lanes"
case .board: "Board"
case .comment: "Comments"
}
}
}
// MARK: The comment family
/// **The post's phrase** 06-history-undo.md's path-shaped "Comment on 'card'" as a menu
/// title, which is the verb on its own.
///
/// Not `name(_:kind:count:)` with a `Verb.comment`, because the composition would read "Comment
/// Comment": here the verb already names its object, which is `Kind.board`'s count-less rule
/// arriving from the other direction. The destination clause a commit subject carries ("on 'Fix
/// login'") is dropped exactly as every other phrase drops it a menu row has to stay short.
public static let comment = "Comment"
// MARK: Composition
/// The phrase for one gesture: `"Move Card"`, `"Move 3 Cards"`, `"Restyle Board"`.
+25
View File
@@ -848,6 +848,31 @@ public final class BannerCenter {
// The buffer is still on screen: the banner says the app could not put those bytes on
// disk, not that they are gone.
if let title { "Couldn't apply source changes to '\(title)'" } else { "Couldn't apply the source changes" }
// **The comment family names the card, because that is the title these operations carry**
// a comment has none of its own (01-storage-format.md § Enhanced schema: "No `title`, no
// `order`"), and the card is the thing on the window in front of them. Each sentence is the
// gesture's own word, the vocabulary's standing rule: they pressed , or Delete, or nothing
// at all in the draft's case.
case let .saveCommentDraft(title):
// **"draft", never "save the comment"**: nothing has been posted, and a banner claiming a
// comment could not be saved would name one that does not exist yet. The typed text is
// still in the composer this says the app could not put it on disk.
if let title { "Couldn't save your draft comment on '\(title)'" } else { "Couldn't save your draft comment" }
case let .postComment(title):
// The Comment button's own word (05-card-window.md The comments column, " posts").
// The draft is untouched on disk, which is what makes this a retry rather than a loss.
if let title { "Couldn't post your comment on '\(title)'" } else { "Couldn't post your comment" }
case let .editComment(title):
if let title { "Couldn't save your edit to a comment on '\(title)'" } else { "Couldn't save your edit to the comment" }
case let .deleteComment(title):
if let title { "Couldn't delete a comment on '\(title)'" } else { "Couldn't delete the comment" }
case .purgeCommentTrash:
// No title, and no mention of a trash the user has never seen: `comments/.trash/` is
// "never a UI surface", so the sentence is about the *card's* files being tidied the
// agent guide's posture, one level down. Nothing is lost either way; the next window
// open sweeps again.
"Couldn't tidy up deleted comments"
}
}
+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
)
}
}
+87
View File
@@ -0,0 +1,87 @@
import Foundation
// MARK: - CommentPath
/// **What a changed path under a card's `comments/` is** the pure classification the announcer and
/// the commit-message composer read (01-storage-format.md § Enhanced schema: "foreign comment changes
/// are described by **path shape** the 'Update agent guide (vN)' mechanism: a changed path under
/// `/comments/<uuid>/` composes 'Comment on card title' / 'Edit comment on' / 'Delete comment
/// on', and the announcer speaks arrivals the same way").
///
/// ### Why a path, and not the snapshot
///
/// Comments are window-scoped and the board snapshot never carries their content, so the two
/// consumers that describe *change* have no diff to read: the composer is "a pure snapshot diff" and
/// the snapshot has nothing to say here, exactly as it has nothing to say about `CLAUDE.md`. Path
/// shape is what is left, and it is enough the verb family is a function of *where* a file sits,
/// not of what it contains.
///
/// ### It classifies, it does not name
///
/// The shape says comment / draft / trashed and which card. Which *verb* that composes needs one more
/// fact the path cannot carry whether the folder is an arrival or a change to one already there
/// and that belongs to the caller with the before-and-after in hand. This type stays a pure function
/// of a string so both consumers can share it without sharing anything else.
///
/// Homed beside `EchoLedger` and `BoardDiff`, which are the two things that turn observed paths into
/// described events.
public struct CommentPath: Sendable, Equatable {
/// The **card**'s path relative to the board root `<lane>/<card>`, or `.trash/<card>` for a
/// trashed card, which carries its thread like any other content.
public let cardPath: String
/// Which of the thread's three homes the path is in.
public let kind: Kind
public enum Kind: Sendable, Equatable {
/// A posted comment `<card>/comments/<uuid>/`. The thread's own content.
case comment(ItemID)
/// The card's single draft `<card>/comments/.draft/`. Composes the quiet
/// "Draft comment on 'card'".
case draft
/// A deleted comment waiting for the close purge `<card>/comments/.trash/<uuid>/`.
case trashed(ItemID)
}
/// The comment's identity, or `nil` for the draft which has none, and is the one member of the
/// thread that is a name rather than an id.
public var id: ItemID? {
switch kind {
case let .comment(id), let .trashed(id): id
case .draft: nil
}
}
/// Classifies one **root-relative, `/`-separated** path, or `nil` when it is not inside a thread.
///
/// The rule is one index: a thread lives at `<lane>/<card>/comments/` and a trashed card's at
/// `.trash/<card>/comments/`, so `comments` is always the third component and the card is always
/// the second one check covers both containers without either being spelled twice.
///
/// Everything else answers `nil`, including the paths that are *nearly* one: `comments/` itself
/// (a container, never an event), a stray folder inside it, `comments/.trash` with no entry under
/// it. A `nil` is not a defect it is this function saying the path is somebody else's to
/// describe.
public static func classify(_ relativePath: String) -> CommentPath? {
let components = relativePath.split(separator: "/", omittingEmptySubsequences: true).map(String.init)
guard components.count >= 4,
components[2].lowercased() == IntegrityRules.commentsFolderName,
IntegrityRules.isIdentityShaped(components[1])
else {
return nil
}
let cardPath = components[0] + "/" + components[1]
let entry = components[3]
if entry.lowercased() == IntegrityRules.commentDraftFolderName {
return CommentPath(cardPath: cardPath, kind: .draft)
}
if entry.lowercased() == IntegrityRules.commentTrashFolderName {
guard components.count >= 5, IntegrityRules.isIdentityShaped(components[4]) else { return nil }
return CommentPath(cardPath: cardPath, kind: .trashed(ItemID(rawValue: components[4])))
}
guard IntegrityRules.isIdentityShaped(entry) else { return nil }
return CommentPath(cardPath: cardPath, kind: .comment(ItemID(rawValue: entry)))
}
}
+4 -3
View File
@@ -376,9 +376,10 @@ public enum BoardLoader: Sendable {
heldCards: children().count,
document: document
))
case .card, .board:
// `kind: board` never reaches here as itself `trashKind` treats it as unrecognized
// and answers by shape so this arm is the card answer and nothing else.
case .card, .board, .comment:
// Neither `kind: board` nor `kind: comment` reaches here as itself `trashKind`
// treats both as unrecognized and answers by shape so this arm is the card answer
// and nothing else.
trash.append(Card(
id: id,
schema: schema,
+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"
}
}
+295
View File
@@ -0,0 +1,295 @@
import Foundation
import os
// MARK: - Comment
/// One comment: a UUID-named folder under a card's `comments/`, holding `index.md` and optionally
/// `attachments/` "a card's anatomy one level down" (01-storage-format.md § Enhanced schema,
/// storage specified 2026-07-29).
///
/// The field table is the common schema minus two and plus one: **no `title`, no `order`**, and
/// `author` self-reported *content* that survives every app write, unlike `modified-by`.
///
/// `Card`'s shape deliberately, one level down: identity, the lenient fields as `FieldValue`s, the
/// attachment listing, and the whole parsed document so unknown and reserved keys ride along
/// uninterpreted.
public struct Comment: Identifiable, Sendable, Equatable {
public let id: ItemID
/// The schema number as read. Lenient here, unlike every other level: a comment defect never
/// refuses anything (§ Enhanced schema), so a missing or unreadable `schema` costs the thread a
/// rendered comment, never a load.
public let schema: FieldValue<Int>
/// Who says they wrote it the app writes the macOS account's full name, agents write their
/// own, tracker sync writes the remote author verbatim. **Missing renders unattributed**; there
/// is no identity system behind it and none is implied.
public let author: FieldValue<String>
/// Load-bearing, unlike anywhere else in the schema: the thread's order *is* `created`
/// ascending (§ Enhanced schema "Ordering is chronology, not ranks").
public let created: FieldValue<Date>
public let modified: FieldValue<Date>
public let modifiedBy: FieldValue<String>
/// The comment's attachment file names flat, top-level regular files, Finder order, through
/// the same enumeration a card's listing uses (`BoardLoader.attachmentNames`), so a chip row and
/// a card's sidebar can never disagree about what a folder holds.
public let attachments: [String]
/// The full parsed `index.md`; unknown and reserved keys ride along uninterpreted.
public let document: FrontmatterDocument
/// The comment's Markdown the card-body subset. Equivalent to `document.body`.
public var body: String { document.body }
/// **The edited indicator is `modified` differing from `created`, and no extra field**
/// (§ Enhanced schema). A post writes both from one `Date`, so a comment that has never been
/// edited reads `false` by construction; one of the pair missing is not evidence of an edit.
public var isEdited: Bool {
guard let created = created.value, let modified = modified.value else { return false }
return modified != created
}
}
// MARK: - CommentThread
/// A card's comment thread, read from disk **window-scoped, outside the board snapshot**
/// (01-storage-format.md § Enhanced schema: "the walk stays O(cards): the card window reads its own
/// thread and the board snapshot never loads comment content").
///
/// ### It never refuses
///
/// There is no `throws` anywhere in this type, and that is the ruling rather than convenience:
/// "**Comment defects never refuse the board** worst case is the stray posture (tolerated, logged,
/// unrendered): a broken leaf annotation must not brick a load; deliberate, proportionate divergence
/// from card fail-fast". A folder that is not identity-shaped, one with no `index.md`, one whose
/// frontmatter does not parse, one that is not UTF-8 each is skipped with a log line, preserved
/// verbatim on disk, and the rest of the thread renders.
///
/// ### The two dot-named folders are not comments
///
/// `comments/.draft/` and `comments/.trash/` are excluded from the listing (§ Enhanced schema), and
/// they are excluded *for free*: both are dot-prefixed, and `BoardLoader.directoryCandidates` skips
/// hidden entries. The exclusion is stated in the enumeration's own rule rather than re-implemented
/// here, exactly as `.trash/` is at board level.
public struct CommentThread: Sendable, Equatable {
/// The thread in display order `created` ascending (see `sorted(_:)` for the fallback).
public let comments: [Comment]
/// Folders under `comments/` that are not comments reported for the log's sake and rendered by
/// nothing. The tolerate tier, one level down.
public let strays: [Stray]
/// Pending work and coerce-tier observations this read found claimed names squatted inside
/// `comments/` or inside one comment, and every lenient field that had no sensible reading. The
/// same typed stream the board walk fills (`IntegrityRules.Defect`), so the heal engine needs no
/// second vocabulary for a thread.
public let defects: [IntegrityRules.Defect]
/// Whether the card has a draft on disk. The composer reads its bytes itself; what a *thread*
/// needs to know is only that one exists, which is the answer restore-on-reopen turns on.
public let hasDraft: Bool
/// One folder under `comments/` the thread would not render, and why never an error, always a
/// log line.
public struct Stray: Sendable, Equatable {
public enum Reason: Sendable, Equatable {
/// Not identity-shaped: a hand-made folder, a nested clone. The shape-only identity
/// predicate one level down.
case notIdentityShaped
/// Identity-shaped with no `index.md` two-step-create tolerance, verbatim from the
/// card rule (01-storage-format.md § Fractal layout Rules).
case missingIndex
/// The bytes are there and could not be read as a comment: not UTF-8, frontmatter that
/// does not parse. The one reason a *card* would have failed the whole load.
case unreadable(message: String)
}
public let name: String
public let reason: Reason
}
public static let empty = CommentThread(comments: [], strays: [], defects: [], hasDraft: false)
// MARK: - Where a thread lives
/// `<card>/comments/` named but never created here. One place, so the loader's read and every
/// write in `CommentWriter.swift` can never disagree about where a thread is
/// (`BoardWriter.trashFolder(inBoard:)`'s precedent).
public static func folder(inCard cardFolder: URL) -> URL {
cardFolder.appendingPathComponent(IntegrityRules.commentsFolderName, isDirectory: true)
}
/// `<card>/comments/.draft/` the card's single draft.
public static func draftFolder(inCard cardFolder: URL) -> URL {
folder(inCard: cardFolder)
.appendingPathComponent(IntegrityRules.commentDraftFolderName, isDirectory: true)
}
/// `<card>/comments/.trash/` undo's backing store, purged at window close.
public static func trashFolder(inCard cardFolder: URL) -> URL {
folder(inCard: cardFolder)
.appendingPathComponent(IntegrityRules.commentTrashFolderName, isDirectory: true)
}
/// `<card>/comments/<id>/` one posted comment.
public static func commentFolder(_ id: ItemID, inCard cardFolder: URL) -> URL {
folder(inCard: cardFolder).appendingPathComponent(id.rawValue, isDirectory: true)
}
/// `<card>/comments/.trash/<id>/` one deleted comment, waiting for the close purge or a Z.
public static func trashedCommentFolder(_ id: ItemID, inCard cardFolder: URL) -> URL {
trashFolder(inCard: cardFolder).appendingPathComponent(id.rawValue, isDirectory: true)
}
/// The identities currently sitting in `comments/.trash/` what a close purge would remove, and
/// what the crash-residue sweep signs its memo with.
///
/// The listing rule is the thread's own (`directoryCandidates` narrowed by the identity
/// predicate), so a stray a hand-editor put in there is neither counted nor purged the same
/// honesty `emptyTrash` keeps at board level.
public static func trashedCommentIDs(inCard cardFolder: URL) -> [ItemID] {
((try? BoardLoader.directoryCandidates(in: trashFolder(inCard: cardFolder))) ?? [])
.filter { IntegrityRules.isIdentityShaped($0.lastPathComponent) }
.map { ItemID(rawValue: $0.lastPathComponent) }
}
// MARK: - Reading
private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "comments")
/// Reads one card's thread. Total: a card with no `comments/`, an unreadable one, or one held by
/// a file answers `.empty`, which is what "no comments yet" looks like and is not a defect this
/// read has any business inventing the squatted-name case *is* reported, as work.
///
/// - Parameter path: the card folder's path relative to the board root (`<lane>/<card>`, or
/// `.trash/<card>` for a trashed one "a trashed card carries its `comments/`"). Carried into
/// every defect so the heal lands wherever the board lives at write time, and into the log
/// lines so a stray names something a human can find.
public static func load(inCard cardFolder: URL, path: String) -> CommentThread {
let threadFolder = folder(inCard: cardFolder)
var defects: [IntegrityRules.Defect] = []
switch IntegrityRules.node(at: threadFolder) {
case nil:
return .empty
case .directory:
break
case .file, .symlink:
// The claimed name held by the wrong kind of node. Detection only, like every other
// defect in this app the displacement is the store's, through the Writer. Nothing else
// about the thread can be read while a file wears the name, so the listing is empty and
// the work is the whole answer.
return CommentThread(
comments: [],
strays: [],
defects: IntegrityRules.squattedClaimedNames(inCardAt: cardFolder, path: path)
.map(IntegrityRules.Defect.claimedNameSquatted),
hasDraft: false
)
}
for squatter in IntegrityRules.squattedClaimedNames(inCommentThreadAt: threadFolder, cardPath: path) {
defects.append(.claimedNameSquatted(squatter))
logger.warning(
"\(path, privacy: .public)/comments/\(squatter.name, privacy: .public): claimed name held by \(squatter.found.description, privacy: .public) — to be displaced"
)
}
var comments: [Comment] = []
var strays: [Stray] = []
// Hidden entries and symlinks are already out, which is exactly how `.draft` and `.trash` are
// excluded from the thread see the type's own note.
for commentURL in (try? BoardLoader.directoryCandidates(in: threadFolder)) ?? [] {
let name = commentURL.lastPathComponent
let commentPath = path + "/" + IntegrityRules.commentsFolderName + "/" + name
guard IntegrityRules.isIdentityShaped(name) else {
strays.append(Stray(name: name, reason: .notIdentityShaped))
logger.warning("\(commentPath, privacy: .public): not a comment identity — ignored")
continue
}
let indexURL = commentURL.appendingPathComponent(IntegrityRules.indexFileName)
guard let data = try? Data(contentsOf: indexURL) else {
strays.append(Stray(name: name, reason: .missingIndex))
logger.warning("\(commentPath, privacy: .public): no index.md — ignored")
continue
}
let document: FrontmatterDocument
do {
document = try BoardLoader.parseDocument(data, path: commentPath)
} catch {
strays.append(Stray(name: name, reason: .unreadable(message: error.reason.description)))
logger.warning(
"\(commentPath, privacy: .public): \(error.reason.description, privacy: .public) — ignored"
)
continue
}
let fields = document.coercedFields
if !fields.isEmpty {
let indexPath = commentPath + "/" + IntegrityRules.indexFileName
defects.append(.coercedFrontmatter(CoercedFrontmatter(path: indexPath, fields: fields)))
for field in fields {
logger.info(
"\(indexPath, privacy: .public): '\(field.key, privacy: .public)' has no sensible reading — \(field.raw, privacy: .public) — rendering the field's default"
)
}
}
for squatter in IntegrityRules.squattedClaimedNames(inCommentAt: commentURL, path: commentPath) {
defects.append(.claimedNameSquatted(squatter))
logger.warning(
"\(commentPath, privacy: .public)/\(squatter.name, privacy: .public): claimed name held by \(squatter.found.description, privacy: .public) — to be displaced"
)
}
comments.append(Comment(
id: ItemID(rawValue: name),
schema: document.schema,
author: document.author,
created: document.created,
modified: document.modified,
modifiedBy: document.modifiedBy,
attachments: BoardLoader.attachmentNames(in: commentURL),
document: document
))
}
return CommentThread(
comments: sorted(comments),
strays: strays,
defects: defects,
hasDraft: IntegrityRules.node(at: draftFolder(inCard: cardFolder)) == .directory
)
}
/// **Chronology, with the undated after the dated** (01-storage-format.md § Enhanced schema,
/// ruled 2026-07-29): "the thread sorts by `created` ascending ties and missing/malformed
/// `created` (coerce-tier fallback, logged) sort after dated siblings, folder-name order".
///
/// The folder-name tie-break compares the **canonical lowercase spelling**, the corpus-wide rule
/// for every folder-name tie-break (§ Ordering: "ordering follows the value-based identity model,
/// never an uppercase folder's ASCII accident") an agent's uppercase `uuidgen` output must not
/// sort into a different place than the same identity spelled lowercase.
static func sorted(_ comments: [Comment]) -> [Comment] {
comments.sorted { lhs, rhs in
switch (lhs.created.value, rhs.created.value) {
case let (left?, right?):
left == right ? isOrderedByName(lhs, rhs) : left < right
case (.some, .none):
true
case (.none, .some):
false
case (.none, .none):
isOrderedByName(lhs, rhs)
}
}
}
private static func isOrderedByName(_ lhs: Comment, _ rhs: Comment) -> Bool {
IntegrityRules.canonicalIdentity(lhs.id.rawValue)
< IntegrityRules.canonicalIdentity(rhs.id.rawValue)
}
}
+503
View File
@@ -0,0 +1,503 @@
import Foundation
// MARK: - Outcomes
/// What one draft save produced on disk the composer's slow cadence has four possible answers and
/// the caller needs to tell them apart (the create is what a first keystroke does, the delete is the
/// emptied-draft rule firing, and `.unchanged` is the ~30 s tick finding nothing to do).
public enum CommentDraftOutcome: Sendable, Equatable {
/// `comments/.draft/` did not exist and now does, carrying the ordinary comment schema.
case created
/// Its body span was replaced and `modified` stamped.
case updated
/// The bytes on disk already said this. Nothing was written and no `mtime` moved.
case unchanged
/// **The emptied-draft rule**: no text, no attachments, so the folder is gone
/// (01-storage-format.md § Enhanced schema "a draft emptied of text with no attachments is
/// deleted by the app, never litter").
case deleted
}
/// What a post landed: the fresh identity, and the instant `created` and `modified` were both
/// stamped with. Both halves are what a redo replays (13-native-undo.md a redo repeats the values
/// its gesture wrote, never re-derives them).
public struct PostedComment: Sendable, Equatable {
public let id: ItemID
/// One `Date` for both stamps, so a freshly posted comment reads "not edited" by construction
/// rather than by rounding (`Comment.isEdited`).
public let posted: Date
}
// MARK: - The comment writer
/// **The comment thread's write primitives** `BoardWriter`'s own vocabulary one level down
/// (01-storage-format.md § Enhanced schema, storage specified 2026-07-29; 05-card-window.md The
/// comments column).
///
/// An extension in its own file for `BoardStoreHistory.swift`'s reason: this is the same type, and
/// the rules it obeys are that type's rules atomic temp+rename through `atomicReplace`, surgical
/// span edits through `FrontmatterDocument`, `modified` stamped and `modified-by` cleared on every
/// content write, receipts dropped in the EchoLedger by the four disk primitives but the *subject*
/// is one thing, and a reader looking for what a comment write does should find it in one place.
///
/// ### What is different one level down, and why
///
/// - **The title every operation carries is the card's.** A comment has no `title` key at all, and
/// the path-shaped verb family names the card ("Comment on 'card'"), so each entry point takes
/// `cardTitle` and hands it to its `WriteOperation`. `withTitle` is identity for all five.
/// - **`author` survives.** `updateIndex` clears `modified-by` and only that, so the lenient
/// self-reported `author` rides through every rewrite untouched, which is the field table's whole
/// point ("unlike `modified-by` it survives app writes").
/// - **Nothing here has an `order`.** Chronology is the thread's order, so there are no ranks to
/// mint, no ladder to thread, and no renumber to fall back on.
/// - **No byte capture, anywhere** (13-native-undo.md): the delete is a move, its inverse is the
/// move back, and the post's inverse is the rename back. Nothing in this file reads a body in
/// order to hold it.
extension BoardWriter {
// MARK: - The draft
/// **Saves the card's single draft** into `comments/.draft/` create on the first save, a body
/// span replacement on every one after (05-card-window.md The comments column: "The composer
/// edits `comments/.draft/`", written "on composer blur, window close, quit, and a lazy interval
/// (~30 s)").
///
/// The sequence:
///
/// 1. **It must be a card** (`checkIsCardFolder`) a thread hangs off a card and nothing else.
/// 2. **The emptied-draft rule first**, before anything is created: a save with no text and no
/// attachments *removes* the folder, and a save with no text against a draft that does not
/// exist creates nothing at all. Typing one character and deleting it must not leave a folder
/// behind ("never litter").
/// 3. **Create**, minting `comments/` on the way if the card has never had one: `schema`,
/// `author`, `created`, `modified`, `kind: comment`. The two stamps share one `Date`.
/// 4. **Update**: the body span and the `modified` stamp, with the identical-bytes gate
/// `writeBody` has and for its reason the ~30 s tick fires whether or not anything changed,
/// and a no-op save that stamped `modified` would make an untouched draft look edited every
/// half minute (and, on a Pro board, commit).
///
/// **"No attachments" is the listing the user sees** (`BoardLoader.attachmentNames`): top-level
/// regular files in `attachments/`, which is exactly the set the composer renders as chips. A
/// draft holding only an empty `attachments/` folder, or only a subfolder, has nothing to lose
/// and is deleted the chips are the promise, not the directory.
@discardableResult
public static func saveCommentDraft(
inCard cardFolder: URL,
body: String,
cardTitle: String?
) throws(BoardWriteError) -> CommentDraftOutcome {
let operation = WriteOperation.saveCommentDraft(title: cardTitle)
try checkIsDirectory(cardFolder, describedAs: "card folder", operation: operation)
try checkIsCardFolder(cardFolder, operation: operation)
let draft = CommentThread.draftFolder(inCard: cardFolder)
let exists = IntegrityRules.node(at: draft) == .directory
if body.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
!exists || BoardLoader.attachmentNames(in: draft).isEmpty {
guard exists else { return .unchanged }
do {
try FileManager.default.removeItem(at: draft)
} catch {
throw BoardWriteError(
operation: operation,
path: draft.path,
reason: .io(message: "could not remove the emptied draft: \(error.localizedDescription)")
)
}
EchoLedger.current?.recordDeletion(at: draft)
return .deleted
}
let indexURL = draft.appendingPathComponent(BoardLoader.indexFileName)
// A folder with no `index.md` takes the create path rather than failing: that is the shape an
// interrupted create leaves, and two-step-create tolerance is one of the fractal rules that
// apply here verbatim (01-storage-format.md § Enhanced schema).
guard exists, FileManager.default.fileExists(atPath: indexURL.path) else {
if !exists {
do {
try FileManager.default.createDirectory(at: draft, withIntermediateDirectories: true)
} catch {
throw BoardWriteError(
operation: operation,
path: draft.path,
reason: .io(message: "could not create the draft folder: \(error.localizedDescription)")
)
}
}
try atomicReplace(text: newCommentText(body: body), at: indexURL, operation: operation)
return .created
}
return try writeCommentBody(at: draft, body: body, operation: operation) ? .updated : .unchanged
}
/// **Posts the draft**: `comments/.draft/` renamed to a fresh lowercase UUID, `created` and
/// `modified` restamped, **in one bracket** (01-storage-format.md § Enhanced schema, ruled
/// 2026-07-29: "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").
///
/// **One `Date` for both stamps**, `newDocumentText`'s convention and here it is load-bearing:
/// the edited indicator is `modified` differing from `created`, so two `Date()` calls straddling
/// a second boundary would post a comment that renders as already edited.
///
/// **The rename is the identity mint**, so nothing is copied and no bytes move: `attachments/`,
/// strays and every unknown key arrive exactly as the draft held them. The `author` the draft was
/// created with rides through the restamp untouched, which is what makes posting a draft written
/// last week still attributed to whoever wrote it.
///
/// A draft folder with no readable `index.md` is minted one first (the create path's tolerance);
/// a draft whose frontmatter cannot be round-tripped refuses the post before the rename
/// discover-before-you-write, `moveItem`'s rule, and the gesture the user pressed is the thing
/// that failed rather than some other comment being tolerated.
public static func postComment(
inCard cardFolder: URL,
cardTitle: String?
) throws(BoardWriteError) -> PostedComment {
let operation = WriteOperation.postComment(title: cardTitle)
try checkIsDirectory(cardFolder, describedAs: "card folder", operation: operation)
try checkIsCardFolder(cardFolder, operation: operation)
let thread = CommentThread.folder(inCard: cardFolder)
let draft = CommentThread.draftFolder(inCard: cardFolder)
guard IntegrityRules.node(at: draft) == .directory else {
throw BoardWriteError(
operation: operation,
path: draft.path,
reason: .unreadable(message: "there is no draft to post")
)
}
let draftIndexURL = draft.appendingPathComponent(BoardLoader.indexFileName)
if !FileManager.default.fileExists(atPath: draftIndexURL.path) {
try atomicReplace(text: newCommentText(body: ""), at: draftIndexURL, operation: operation)
}
_ = try checkIndexIsRewritable(inItemFolder: draft, operation: operation)
let name = freshUUIDName(in: thread, avoiding: [])
try renameFolder(draft, toSiblingNamed: name, operation: operation)
let posted = thread.appendingPathComponent(name, isDirectory: true)
let now = Date()
try restampComment(at: posted, to: now, operation: operation)
return PostedComment(id: ItemID(rawValue: name), posted: now)
}
// MARK: - Editing
/// **An inline comment edit's save** the body-edit session in miniature (05-card-window.md
/// The comments column: "debounced saves to the comment's own file keep it crash-safe, Save (or
/// ) ends the session as its commit point").
///
/// `writeBody`'s three properties, one level down: the body span and nothing else changes, the
/// bytes above the closing delimiter are the bytes they were, and **identical bytes write
/// nothing** (returning `false` with an untouched `mtime`). `modified` is stamped and
/// `modified-by` cleared; `author` and `created` are not touched, which is exactly how "· edited"
/// comes to be true without a field existing for it.
///
/// **Cancel is not here.** The session reverts to its own start-of-session bytes, which is the
/// UI session's business (phase 2) and not a Writer primitive: there is no capture in this file.
///
/// - Returns: `true` when bytes were written, `false` when the body on disk already matched.
@discardableResult
public static func editComment(
at commentFolder: URL,
body: String,
cardTitle: String?
) throws(BoardWriteError) -> Bool {
let operation = WriteOperation.editComment(title: cardTitle)
try checkIsCommentFolder(commentFolder, operation: operation)
return try writeCommentBody(at: commentFolder, body: body, operation: operation)
}
// MARK: - Delete, restore, purge
/// **Deleting a comment is a move into `comments/.trash/`** (01-storage-format.md § Enhanced
/// schema, re-ruled 2026-07-29: "the materialized-trash pattern one level down, joining `.draft`
/// in the claimed names, excluded from the thread, never a UI surface").
///
/// `deleteCardToTrash`'s body one level down, minus the thing a comment does not have: there is
/// no rank to mint, because the trash a comment lands in has no order at all it is undo's
/// backing store for the life of one window, not a browsable column.
///
/// **The stamp is the container rule's plainest instance, again**: the move changes the comment's
/// container, so `updateIndex` stamps `modified` and clears `modified-by` without a trash branch
/// existing anywhere (§ Enhanced schema: "The container-change stamping rule applies the move
/// stamps `modified`"). `kind: .comment` is passed rather than derived so the on-touch backfill
/// cannot mistake a folder inside a `.trash` for a board-trash resident.
///
/// **No confirm, no capture** undo is the net, and its inverse is `restoreComment`.
@discardableResult
public static func deleteComment(
at commentFolder: URL,
cardTitle: String?
) throws(BoardWriteError) -> ItemID {
let operation = WriteOperation.deleteComment(title: cardTitle)
try checkIsCommentFolder(commentFolder, operation: operation)
// The move rewrites this file at the destination, so a file that cannot be round-tripped
// refuses before the folder travels (`moveItem`'s discover-before-you-write rule).
_ = try checkIndexIsRewritable(inItemFolder: commentFolder, operation: operation)
let trash = commentFolder
.deletingLastPathComponent()
.appendingPathComponent(IntegrityRules.commentTrashFolderName, isDirectory: true)
try moveComment(commentFolder, into: trash, operation: operation)
return ItemID(rawValue: commentFolder.lastPathComponent)
}
/// **The delete's inverse: the move back out** (13-native-undo.md Interaction with the trash,
/// the comment clause: "a comment delete is a move into the card's `comments/.trash/` so its
/// inverse is the ordinary move back").
///
/// It stamps for the reason the forward move does the container changed again and it takes
/// `.deleteComment`'s own vocabulary word, `.delete`'s "there is no `restore` case" precedent one
/// level down: a failed restore is a comment that could not be moved, and inventing a sixth
/// operation to say so would name a gesture the user never pressed (they pressed Z).
public static func restoreComment(
_ id: ItemID,
inCard cardFolder: URL,
cardTitle: String?
) throws(BoardWriteError) {
let operation = WriteOperation.deleteComment(title: cardTitle)
let trashed = CommentThread.trashedCommentFolder(id, inCard: cardFolder)
try checkIsDirectory(trashed, describedAs: "comment folder", operation: operation)
try moveComment(trashed, into: CommentThread.folder(inCard: cardFolder), operation: operation)
}
/// **Empties one card's `comments/.trash/`** at card-window close, and as the crash-residue
/// sweep at the next open (01-storage-format.md § Enhanced schema: "purged when the card window
/// closes (rides the close flush; crash residue sweeps at the next card-window open,
/// armed-then-cleared like every heal memo)").
///
/// `emptyTrash`'s rules, one level down and for its reasons:
///
/// - **The entries, not the container.** Only identity-shaped children are removed; a stray a
/// hand-editor put in there keeps the verbatim posture, and the emptied folder is left standing
/// because the next delete would only recreate it.
/// - Removal is per entry, in order; a failure stops the batch and throws, and everything already
/// removed stays removed.
/// - A card with no thread trash removes nothing and answers `[]`.
///
/// **It registers no undo step** the permanent-delete posture (13-native-undo.md), which is
/// also what makes the leftover comment steps on the board stack go stale and skip with a banner
/// rather than resurrect a folder that is gone.
@discardableResult
public static func purgeCommentTrash(inCard cardFolder: URL) throws(BoardWriteError) -> [ItemID] {
let operation = WriteOperation.purgeCommentTrash
var purged: [ItemID] = []
for entry in childCandidates(of: CommentThread.trashFolder(inCard: cardFolder)) {
do {
try FileManager.default.removeItem(at: entry)
} catch {
throw BoardWriteError(
operation: operation,
path: entry.path,
reason: .io(message: "could not remove folder: \(error.localizedDescription)")
)
}
EchoLedger.current?.recordDeletion(at: entry)
purged.append(ItemID(rawValue: entry.lastPathComponent))
}
return purged
}
// MARK: - The post's inverse family
/// **The post's inverse: the posted folder renamed back to `.draft`** (13-native-undo.md, the
/// comment clause: "post-undo naturally rides the same rail" the move-based inverse family,
/// with no byte capture in any tier).
///
/// **A rename and nothing else, so it stamps nothing.** The folder keeps its parent, so no
/// container changed, and the existing write discipline answers without a rule of its own: "a
/// heal that only renames or relocates folders and files never opens `index.md` and stamps
/// nothing" (§ Validation and healing) an identity change, not an edit. The post-time
/// `created`/`modified` therefore survive on the un-posted draft, and the next post restamps them
/// anyway, which is the whole reason the post restamps at all.
///
/// **It refuses a `.draft` that is already there**, rather than clobbering it: two drafts cannot
/// exist, and one the user has typed since is not this step's to overwrite. The step's own
/// staleness predicate expects exactly that absence, so in practice the refusal is unreachable
/// it stands because the Writer owns the bytes and promises this against every caller.
public static func unpostComment(
_ id: ItemID,
inCard cardFolder: URL,
cardTitle: String?
) throws(BoardWriteError) {
let operation = WriteOperation.postComment(title: cardTitle)
let posted = CommentThread.commentFolder(id, inCard: cardFolder)
try checkIsDirectory(posted, describedAs: "comment folder", operation: operation)
let draft = CommentThread.draftFolder(inCard: cardFolder)
guard IntegrityRules.node(at: draft) == nil else {
throw BoardWriteError(
operation: operation,
path: draft.path,
reason: .io(message: "a draft is already here")
)
}
try renameFolder(posted, toSiblingNamed: IntegrityRules.commentDraftFolderName, operation: operation)
}
/// **The post, replayed** the same captured identity and the same captured instant
/// (13-native-undo.md: a redo repeats the values its gesture wrote). Re-minting here would produce
/// a *different* comment, and every step registered above this one on the stack that names the
/// posted id would then name nothing `recreateItem`'s reasoning, one level down.
public static func repostComment(
as id: ItemID,
inCard cardFolder: URL,
stamping instant: Date,
cardTitle: String?
) throws(BoardWriteError) {
let operation = WriteOperation.postComment(title: cardTitle)
let draft = CommentThread.draftFolder(inCard: cardFolder)
try checkIsDirectory(draft, describedAs: "draft folder", operation: operation)
let posted = CommentThread.commentFolder(id, inCard: cardFolder)
guard IntegrityRules.node(at: posted) == nil else {
throw BoardWriteError(
operation: operation,
path: posted.path,
reason: .io(message: "something already exists here")
)
}
try renameFolder(draft, toSiblingNamed: id.rawValue, operation: operation)
try restampComment(at: posted, to: instant, operation: operation)
}
// MARK: - Shared mechanics
/// The frontmatter text for a comment the app is minting outright `newDocumentText`'s twin, and
/// its key order minus the two fields a comment has not got.
///
/// `schema`, `author`, `created`, `modified`, `kind` simply the order `set` is called in, with
/// `kind` last where the common table puts it and where the on-touch backfill would append one.
///
/// **`author` is the macOS account's full name** (01-storage-format.md § Enhanced schema: "the
/// app writes the macOS account's full name (the identity 06-history-undo.md's derived default
/// already uses)"). `NSFullUserName()` is that identity; 06's derived commit default is Pro's and
/// is not coded yet, so this is the first place the app spells it. An empty answer a stripped
/// account record writes **no key at all** rather than `author: ""`: missing renders
/// unattributed, and an empty string is a real (if blank) author, the `title` rule's precedent.
private static func newCommentText(body: String) -> String {
var document = FrontmatterDocument(body: body)
document.set(FrontmatterKeys.schema, to: .int(BoardLoader.supportedSchema))
let author = NSFullUserName()
if !author.isEmpty {
document.set(FrontmatterKeys.author, to: .string(author))
}
let now = Date()
document.set(FrontmatterKeys.created, to: .date(now))
document.set(FrontmatterKeys.modified, to: .date(now))
document.set(FrontmatterKeys.kind, to: .string(IntegrityRules.ObjectKind.comment.rawValue))
return document.serialized()
}
/// Replaces a comment's body span and stamps `writeBody`'s four steps against a folder that is
/// not identity-shaped when it is the draft, which is the only reason this is not that call.
private static func writeCommentBody(
at folder: URL,
body: String,
operation: WriteOperation
) throws(BoardWriteError) -> Bool {
let indexURL = folder.appendingPathComponent(BoardLoader.indexFileName)
var document = try readDocument(at: indexURL, operation: operation)
try checkEditable(document, at: indexURL, operation: operation)
guard document.body != body else { return false }
document.body = body
IntegrityRules.healOnTouch(&document, kind: .comment)
document.set(FrontmatterKeys.modified, to: .date(Date()))
document.remove(FrontmatterKeys.modifiedBy)
try atomicReplace(text: document.serialized(), at: indexURL, operation: operation)
return true
}
/// Sets `created` and `modified` to one instant the post's whole frontmatter edit, spelled
/// directly rather than through `updateIndex` for one reason: `updateIndex` stamps `modified`
/// with a `Date()` of its own, and a post whose two stamps came from two clock reads could land
/// either side of a second boundary and render as edited the moment it appeared.
///
/// Everything else `updateIndex` would have done is here in its order: refuse an uneditable
/// shape, run the on-touch backfill, clear `modified-by`, replace atomically.
private static func restampComment(
at folder: URL,
to instant: Date,
operation: WriteOperation
) throws(BoardWriteError) {
let indexURL = folder.appendingPathComponent(BoardLoader.indexFileName)
var document = try readDocument(at: indexURL, operation: operation)
try checkEditable(document, at: indexURL, operation: operation)
IntegrityRules.healOnTouch(&document, kind: .comment)
document.set(FrontmatterKeys.created, to: .date(instant))
document.set(FrontmatterKeys.modified, to: .date(instant))
document.remove(FrontmatterKeys.modifiedBy)
try atomicReplace(text: document.serialized(), at: indexURL, operation: operation)
}
/// The physical half of both container-changing comment moves into `comments/.trash/` and back
/// out of it. `moveIntoTrash`'s shape: mint the destination if absent, move, then rewrite the
/// arrived `index.md` so the container change stamps.
private static func moveComment(
_ folder: URL,
into destination: URL,
operation: WriteOperation
) throws(BoardWriteError) {
do {
try FileManager.default.createDirectory(at: destination, withIntermediateDirectories: true)
} catch {
throw BoardWriteError(
operation: operation,
path: destination.path,
reason: .io(message: "could not create the folder: \(error.localizedDescription)")
)
}
let arrived = destination.appendingPathComponent(folder.lastPathComponent, isDirectory: true)
do {
try FileManager.default.moveItem(at: folder, to: arrived)
} catch {
throw BoardWriteError(
operation: operation,
path: folder.path,
reason: .io(message: "could not move the comment: \(error.localizedDescription)")
)
}
// The move pair reads correctly from either end, `moveIntoTrash`'s receipt and for its
// reason: the thread sees an absence, the trash sees an arrival.
EchoLedger.current?.recordMove(from: folder, to: arrived)
try updateIndex(inItemFolder: arrived, kind: .comment, operation: operation) { _ in }
}
/// Refuses any folder that is not a **posted comment**: identity-shaped, directly under a card's
/// `comments/`.
///
/// `checkIsCardFolder`'s mirror one level down, and it deliberately refuses the two dot-named
/// folders as well as everything else: `.draft` is the composer's, reached through
/// `saveCommentDraft`, and a folder inside `comments/.trash/` is undo's neither is editable or
/// deletable as a comment, and letting either through this door would put a surface with no
/// window behind it on disk (`readRawSource`'s rule).
private static func checkIsCommentFolder(
_ folder: URL,
operation: WriteOperation
) throws(BoardWriteError) {
try checkIsDirectory(folder, describedAs: "comment folder", operation: operation)
// The parent is named explicitly rather than asked of `placement`, which answers `.comment`
// for a folder inside `comments/.trash/` too correct for the *kind* it stamps, and exactly
// the case this guard has to keep out.
guard IntegrityRules.isIdentityShaped(folder.lastPathComponent),
folder.deletingLastPathComponent().lastPathComponent.lowercased()
== IntegrityRules.commentsFolderName
else {
throw BoardWriteError(
operation: operation,
path: folder.path,
reason: .unreadable(message: "folder is not a comment")
)
}
}
}
+16
View File
@@ -589,6 +589,22 @@ public enum FrontmatterKeys {
public static let remote = "remote"
public static let remoteState = "remote-state"
/// **A comment's self-reported author** (01-storage-format.md § Enhanced schema, the
/// `kind: comment` field table): "lenient self-reported *content*, not overlay: **unlike
/// `modified-by` it survives app writes**; the app writes the macOS account's full name, agents
/// write their own, tracker sync writes the remote author verbatim, and missing renders
/// unattributed".
///
/// **Named here without joining `schemaOwned`**, `remote`'s precedent and its reason turned
/// around: the key belongs to the comment field table and to no other kind, so on a board, a lane
/// or a card it is an ordinary unknown key and `schemaOwned` is exactly the set the card
/// window's Details section subtracts. Listing it would *hide* a hand-added `author:` from the one
/// surface that exists to show hand-added keys; a comment has no such surface to be affected
/// either way.
///
/// Nothing clears it, anywhere: `updateIndex` clears `modified-by` and only that.
public static let author = "author"
public static let schemaOwned: Set<String> = [
schema, title, order, width, created, modified, modifiedBy, deleted, background, icon,
iconColor, kind,
+8
View File
@@ -95,6 +95,7 @@ extension FrontmatterDocument {
record(FrontmatterKeys.created, created)
record(FrontmatterKeys.modified, modified)
record(FrontmatterKeys.modifiedBy, modifiedBy)
record(FrontmatterKeys.author, author)
record(FrontmatterKeys.background, background)
record(FrontmatterKeys.icon, icon)
record(FrontmatterKeys.iconColor, iconColor)
@@ -158,6 +159,13 @@ extension FrontmatterDocument {
/// Schema-owned, not an unknown key: the app clears it on every app-mediated write.
public var modifiedBy: FieldValue<String> { read(FrontmatterKeys.modifiedBy, Self.string) }
/// A comment's self-reported `author` lenient like every other string field, and **content**
/// rather than overlay: no app write ever clears it (01-storage-format.md § Enhanced schema; see
/// `FrontmatterKeys.author`). On any other kind the key is an ordinary unknown one; reading it
/// here costs the same one pass and keeps the coerce tier's trace complete for the one kind whose
/// field table names it.
public var author: FieldValue<String> { read(FrontmatterKeys.author, Self.string) }
/// The object's kind as written `board`, `lane`, `card` (01-storage-format.md § Frontmatter,
/// re-ruled 2026-07-29). Lenient like every other string field: any scalar coerces to the text
/// the author typed, and **the value is never policed** a reading outside the schema's three
+156 -28
View File
@@ -110,21 +110,41 @@ public enum IntegrityRules: Sendable {
public static let trashFolderName = ".trash"
/// A card's attachment folder (01-storage-format.md § Attachments) the one folder the app
/// ever creates under a card.
/// ever creates under a card. **Fractal**: a comment folder has one too (§ Enhanced schema).
public static let attachmentsFolderName = "attachments"
/// The file every level's content lives in.
public static let indexFileName = "index.md"
/// A card's **comment thread** (01-storage-format.md § Enhanced schema) a plain reserved
/// child, never a level and never identity; the identities are the UUID folders inside it.
public static let commentsFolderName = "comments"
/// The card's single comment draft, inside `comments/` "a reserved dot-named folder holding
/// ordinary comment schema excluded from the thread listing" (§ Enhanced schema, ruled
/// 2026-07-29).
public static let commentDraftFolderName = ".draft"
/// The thread's own trash **the board's name one level down**, deliberately the same spelling:
/// "the materialized-trash pattern one level down, joining `.draft` in the claimed names"
/// (§ Enhanced schema). Undo's backing store, never a UI surface.
public static let commentTrashFolderName = trashFolderName
/// **The card-level reserved names** (01-storage-format.md § Fractal layout Rules): the
/// card's own `index.md` plus the two reserved children. `comments` is listed because the schema
/// reserves the name, not because anything writes it yet.
/// card's own `index.md` plus the two reserved children.
///
/// **Compared lowercased**, because the filesystem this runs on usually is: a file spelled
/// `Index.md` *is* the card's index to `fileExists`, and a case-sensitive reservation check
/// would hand the loose-file relocation a card's own content to move into `attachments/`.
public static let reservedCardChildNames: Set<String> = [
indexFileName, attachmentsFolderName, "comments",
indexFileName, attachmentsFolderName, commentsFolderName,
]
/// **The same table inside one comment folder** "a card's anatomy one level down, so the
/// fractal rules apply verbatim" (01-storage-format.md § Enhanced schema). A comment has no
/// `comments/` of its own: replies are deliberately deferred and flat is this iteration's rule.
public static let reservedCommentChildNames: Set<String> = [
indexFileName, attachmentsFolderName,
]
/// What kind of node a name is allowed to be.
@@ -188,14 +208,13 @@ public enum IntegrityRules: Sendable {
/// name) displaces by the same ladder (`attachments` `attachments 2`), so imports, Finder drops,
/// and the sidebar listing never fail one gesture at a time against a squatted name").
///
/// **`attachments` displaces; `comments` does not**, and the split is the *timing principle*
/// rather than a hedge 01 calls the reserved-but-unconsumed `comments` "the timing principle's own
/// illustration": nothing reads that name until the tracker era, so a wrong-kind holder degrades no
/// behavior while it stands and stays a **tolerated stray** today, joining the scheduled class the
/// day the name becomes load-bearing. `attachments`, by contrast, is load-bearing now: while a file
/// wears the name, every import into that card, every Finder drop on it, and the card window's
/// listing are broken which is exactly the "proactive when the defect is load-bearing now"
/// condition (§ Validation and healing).
/// **`comments` graduated with the feature** (the timing principle, run forwards): 01 called the
/// reserved-but-unconsumed name "the timing principle's own illustration" "a wrong-kind holder is
/// a tolerated stray today and joins the scheduled class **the day the name becomes load-bearing**".
/// That day is this one: a file or symlink wearing `comments` now breaks the card window's whole
/// thread no draft can be saved, no comment posted, nothing read which is exactly the
/// "proactive when the defect is load-bearing now" condition (§ Validation and healing).
/// `attachments` was load-bearing already and displaced from the start.
///
/// `index.md` is deliberately not here. It is not a *reserved child* the app protects from
/// squatters it is the card's content, and a directory named `index.md` makes the folder an
@@ -203,7 +222,26 @@ public enum IntegrityRules: Sendable {
/// would mean the app deciding a folder's content is a squatter.
public static let claimedCardChildNames: [ClaimedName] = [
ClaimedName(name: attachmentsFolderName, expected: .directory, displacesSquatters: true),
ClaimedName(name: "comments", expected: .directory, displacesSquatters: false),
ClaimedName(name: commentsFolderName, expected: .directory, displacesSquatters: true),
]
/// **The names claimed inside a card's `comments/`** the thread's two lifecycle folders
/// (01-storage-format.md § Enhanced schema: "`.draft` joins the claimed names (a wrong-kind node
/// squatting it displaces by the ladder)", and `comments/.trash/` "joining `.draft` in the claimed
/// names").
///
/// Both displace: a file wearing `.draft` makes the composer unsaveable and a file wearing
/// `.trash` makes every comment delete fail, so neither is latent while it stands.
public static let claimedCommentThreadNames: [ClaimedName] = [
ClaimedName(name: commentDraftFolderName, expected: .directory, displacesSquatters: true),
ClaimedName(name: commentTrashFolderName, expected: .directory, displacesSquatters: true),
]
/// **The card's table, one level down** a comment's own `attachments`, claimed exactly as a
/// card's is (§ Enhanced schema: "displacement of a squatted `attachments`" is named among the
/// fractal rules that "apply verbatim").
public static let claimedCommentChildNames: [ClaimedName] = [
ClaimedName(name: attachmentsFolderName, expected: .directory, displacesSquatters: true),
]
/// The claimed names as the lane walk needs them: lowercased, for a `contains` against a
@@ -262,9 +300,43 @@ public enum IntegrityRules: Sendable {
/// - Parameter path: the card folder's path **relative to the board root**, carried into the defect
/// so the write lands wherever the board lives at heal time (`LooseCardFiles`' convention).
public static func squattedClaimedNames(inCardAt cardFolder: URL, path: String) -> [ClaimedNameSquatter] {
claimedCardChildNames.compactMap { claimed in
squatters(among: claimedCardChildNames, in: cardFolder, at: .card(path: path))
}
/// The claimed-name defects inside one card's **`comments/`** `.draft` and `.trash`
/// (`claimedCommentThreadNames`, 01-storage-format.md § Enhanced schema).
///
/// **Window-scoped, unlike its card-level twin**: the board walk stays O(cards) and never opens a
/// thread (§ Enhanced schema "Comments are window-scoped, outside the board snapshot"), so this
/// is asked by the card window's own thread read, not by the loader.
///
/// - Parameter cardPath: the card folder's path relative to the board root, so the heal lands
/// wherever the board lives at write time (`LooseCardFiles`' convention).
public static func squattedClaimedNames(
inCommentThreadAt threadFolder: URL,
cardPath: String
) -> [ClaimedNameSquatter] {
squatters(among: claimedCommentThreadNames, in: threadFolder, at: .commentThread(cardPath: cardPath))
}
/// The claimed-name defects inside **one comment folder** its `attachments`, the card's rule
/// read one level down (`claimedCommentChildNames`). Window-scoped, for its sibling's reason.
///
/// - Parameter path: the comment folder's path relative to the board root.
public static func squattedClaimedNames(inCommentAt commentFolder: URL, path: String) -> [ClaimedNameSquatter] {
squatters(among: claimedCommentChildNames, in: commentFolder, at: .comment(path: path))
}
/// The shared body of the three plural detections: one table, one folder, one location. The board
/// root's singular answer stays its own, for the reason its doc comment gives.
private static func squatters(
among table: [ClaimedName],
in folder: URL,
at location: ClaimedNameSquatter.Location
) -> [ClaimedNameSquatter] {
table.compactMap { claimed in
guard claimed.displacesSquatters,
let found = node(at: cardFolder.appendingPathComponent(claimed.name)),
let found = node(at: folder.appendingPathComponent(claimed.name)),
found != claimed.expected
else {
return nil
@@ -273,7 +345,7 @@ public enum IntegrityRules: Sendable {
name: claimed.name,
found: found,
expected: claimed.expected,
location: .card(path: path)
location: location
)
}
}
@@ -281,13 +353,14 @@ public enum IntegrityRules: Sendable {
// MARK: - Object kinds
/// The kinds the schema knows (01-storage-format.md § Frontmatter Common to all levels, the
/// `kind` row). `comment` is reserved with the enhanced schema and deliberately absent until it
/// lands an unrecognized value on disk is trusted as itself and never policed, so nothing here
/// has to anticipate it.
/// `kind` row). `comment` joined them with the enhanced schema's storage (§ Enhanced schema, the
/// `kind: comment` field table) the first kind whose position is not a *level*: a comment lives
/// under a card's `comments/`, which is a reserved child rather than a depth.
public enum ObjectKind: String, Sendable, Equatable, CaseIterable {
case board
case lane
case card
case comment
}
/// Where a folder's **position** places it "level is position" (01-storage-format.md
@@ -298,6 +371,9 @@ public enum IntegrityRules: Sendable {
case card
/// Identity-shaped, under something that is neither a lane nor the trash a lane.
case lane
/// Under a card's `comments/` a posted comment, or the `.draft` that is one in every
/// respect but its name (01-storage-format.md § Enhanced schema: "ordinary comment schema").
case comment
/// Inside `.trash/`, where the container is flat and **position cannot answer**: use
/// `trashKind(kindValue:hasIdentityShapedChildIndex:)`.
case insideTrash
@@ -313,13 +389,37 @@ public enum IntegrityRules: Sendable {
/// The trash check sits *between* the two identity checks deliberately: a trashed card and a
/// live lane are both identity-shaped folders whose parent is not, and the container is the only
/// thing that tells them apart.
///
/// **The `comments/` check sits ahead of the trash's**, and ahead of the lane's for the reason
/// that matters: a posted comment is an identity-shaped folder whose parent is not identity-shaped
/// shape-identical to a lane so without it every comment would read as a lane and the on-touch
/// backfill would stamp `kind: lane` into a thread.
///
/// **One ambiguity two names cannot resolve**: `comments/.trash/<uuid>` answers `.insideTrash`
/// here, because `.trash` is the board's spelling one level down. `placement(ofFolder:)` is the
/// form that can tell them apart, and every caller holding a URL should use it.
public static func placement(ofFolderNamed name: String, inParentNamed parent: String) -> Placement {
if isIdentityShaped(parent) { return .card }
if parent.lowercased() == commentsFolderName { return .comment }
if parent.lowercased() == trashFolderName { return .insideTrash }
if isIdentityShaped(name) { return .lane }
return .unknown
}
/// The same rule read off a **URL** the only form that can tell the board's `.trash/` from a
/// comment thread's own one level down, since the two share a name and the two-name rule sees
/// only the name.
public static func placement(ofFolder url: URL) -> Placement {
let parent = url.deletingLastPathComponent()
let placement = placement(ofFolderNamed: url.lastPathComponent, inParentNamed: parent.lastPathComponent)
guard placement == .insideTrash,
parent.deletingLastPathComponent().lastPathComponent.lowercased() == commentsFolderName
else {
return placement
}
return .comment
}
/// **The trash's `kind` discriminator** (01-storage-format.md § Deletion, re-ruled 2026-07-29
/// the value-names-the-kind posture): depth defines meaning on the live board, but the trash is
/// flat, and an empty lane folder is shape-identical to a card folder.
@@ -330,9 +430,10 @@ public enum IntegrityRules: Sendable {
/// identity-shaped children with their own `index.md` lane (the key backfills on the next
/// touch), else card.
///
/// `kind: board` in the trash is *not* a third answer: a board is not a thing that can be
/// trashed, so the value is unrecognized here and shape decides the same shrug an arbitrary
/// string gets.
/// `kind: board` in the trash is *not* a third answer, and neither is `kind: comment`: neither is
/// a thing that can be in the *board's* trash (a deleted comment moves into its own thread's
/// `comments/.trash/`, 01-storage-format.md § Enhanced schema), so both values are unrecognized
/// here and shape decides the same shrug an arbitrary string gets.
///
/// The shape half is `@autoclosure` so that the rule stays a pure function of two facts while
/// its caller pays for the directory listing **only when the value did not answer** which on
@@ -344,7 +445,7 @@ public enum IntegrityRules: Sendable {
switch kindValue.flatMap(ObjectKind.init(rawValue:)) {
case .lane: return .lane
case .card: return .card
case .board, nil: return hasIdentityShapedChildIndex() ? .lane : .card
case .board, .comment, nil: return hasIdentityShapedChildIndex() ? .lane : .card
}
}
@@ -388,9 +489,14 @@ public enum IntegrityRules: Sendable {
/// Whether an object of `kind` must carry `order` the per-kind field table, as a rule rather
/// than as two hand-written call sites in the loader's walk.
///
/// **A comment carries none, and never gains one** (01-storage-format.md § Enhanced schema:
/// "**No `title`, no `order`**" "Ordering is chronology, not ranks", because a conversation's
/// semantics *are* chronology and tracker-synced comments carry independent clocks where minted
/// ranks would interleave arbitrarily).
public static func requiresOrder(_ kind: ObjectKind) -> Bool {
switch kind {
case .board: false
case .board, .comment: false
case .lane, .card: true
}
}
@@ -533,12 +639,20 @@ public enum IntegrityRules: Sendable {
/// read at the moment of healing (`AgentGuide.inspect`), not something a tree walk reports.
/// It is a class here because the engine treats it exactly like the others same gates,
/// same memo, same clear-on-success.
///
/// `commentTrashResidue` is the second such class, for the same reason one level down: the
/// residue is whatever a crashed session left in one card's `comments/.trash/`, read at the
/// moment the card window opens (01-storage-format.md § Enhanced schema "purged when the
/// card window closes; crash residue sweeps at the next card-window open, armed-then-cleared
/// like every heal memo"). The board walk never opens a thread, so no tree walk could report
/// it.
public enum Class: Sendable, Equatable, Hashable, CaseIterable {
case looseCardFiles
case legacyTombstone
case claimedNameSquatted
case duplicateIdentity
case staleAgentGuide
case commentTrashResidue
}
/// The scheduled-heal class this defect belongs to, or **`nil` where there is no heal** the
@@ -899,21 +1013,35 @@ public struct ClaimedNameSquatter: Sendable, Equatable {
public enum Location: Sendable, Equatable {
case boardRoot
case card(path: String)
/// A card's `comments/` container where `.draft` and `.trash` are claimed
/// (01-storage-format.md § Enhanced schema). `cardPath` names the *card*, so the one thing a
/// caller has to know is where the card is, exactly as `.card` asks.
case commentThread(cardPath: String)
/// One comment's own folder where `attachments` is claimed, the card's rule read one level
/// down. `path` is the comment folder's, root-relative.
case comment(path: String)
/// The folder the claimed name lives in, under `root`.
public func folder(under root: URL) -> URL {
switch self {
case .boardRoot: root
case let .card(path): root.appendingPathComponent(path, isDirectory: true)
case .boardRoot:
root
case let .card(path), let .comment(path):
root.appendingPathComponent(path, isDirectory: true)
case let .commentThread(cardPath):
root
.appendingPathComponent(cardPath, isDirectory: true)
.appendingPathComponent(IntegrityRules.commentsFolderName, isDirectory: true)
}
}
/// The location as a signature component `""` for the board root, so the existing root-level
/// signature spelling is unchanged and only a card-level defect adds a path segment.
/// signature spelling is unchanged and only a nested defect adds path segments.
var signatureComponent: String {
switch self {
case .boardRoot: ""
case let .card(path): path + "/"
case let .card(path), let .comment(path): path + "/"
case let .commentThread(cardPath): cardPath + "/" + IntegrityRules.commentsFolderName + "/"
}
}
}