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) 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 }) { for entry in entries.sorted(by: { $0.lastPathComponent < $1.lastPathComponent }) {
guard !excluded.contains(entry.lastPathComponent.lowercased()) else { continue } 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 // Between items, never mid-item: this is the whole of "checks cancellation between
// items", and the reason the copy is a walk at all. // 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 /// 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 /// materializing it hollow from the manifest's embedded `index.md`. Failing to stage is therefore
/// as loud as it should be, one gesture later. /// 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) { private func stage(_ jobs: [StagingJob], into stagingDir: URL) {
enqueue { [jobs, stagingDir] in enqueue { [jobs, stagingDir] in
guard (try? FileManager.default.createDirectory( guard (try? FileManager.default.createDirectory(
@@ -485,6 +492,7 @@ public final class ClipboardStore {
)) != nil else { return } )) != nil else { return }
for job in jobs { for job in jobs {
try? FileManager.default.copyItem(at: job.source, to: job.destination) 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 card
case lane case lane
case board 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 { var singular: String {
switch self { switch self {
case .card: "Card" case .card: "Card"
case .lane: "Lane" case .lane: "Lane"
case .board: "Board" case .board: "Board"
case .comment: "Comment"
} }
} }
@@ -78,10 +83,22 @@ public enum HistoryPhrase {
case .card: "Cards" case .card: "Cards"
case .lane: "Lanes" case .lane: "Lanes"
case .board: "Board" 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 // MARK: Composition
/// The phrase for one gesture: `"Move Card"`, `"Move 3 Cards"`, `"Restyle Board"`. /// 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 // The buffer is still on screen: the banner says the app could not put those bytes on
// disk, not that they are gone. // disk, not that they are gone.
if let title { "Couldn't apply source changes to '\(title)'" } else { "Couldn't apply the source changes" } 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, heldCards: children().count,
document: document document: document
)) ))
case .card, .board: case .card, .board, .comment:
// `kind: board` never reaches here as itself `trashKind` treats it as unrecognized // Neither `kind: board` nor `kind: comment` reaches here as itself `trashKind`
// and answers by shape so this arm is the card answer and nothing else. // treats both as unrecognized and answers by shape so this arm is the card answer
// and nothing else.
trash.append(Card( trash.append(Card(
id: id, id: id,
schema: schema, schema: schema,
+192 -23
View File
@@ -1,4 +1,5 @@
import Foundation import Foundation
import os
/// Turns a mutation into a filesystem operation the single point through which every write /// 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 /// 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 /// a kind onto whatever was pointed at, which is the one thing the value-names-the-kind posture
/// cannot afford. /// cannot afford.
private static func derivedKind(ofItemFolder folder: URL) -> IntegrityRules.ObjectKind? { private static func derivedKind(ofItemFolder folder: URL) -> IntegrityRules.ObjectKind? {
switch IntegrityRules.placement( // The URL form, deliberately: it is the only one that can tell the board's `.trash/` from a
ofFolderNamed: folder.lastPathComponent, // comment thread's own `comments/.trash/`, which share a name.
inParentNamed: folder.deletingLastPathComponent().lastPathComponent switch IntegrityRules.placement(ofFolder: folder) {
) {
case .card: case .card:
return .card return .card
case .lane: case .lane:
return .lane return .lane
case .comment:
return .comment
case .insideTrash: case .insideTrash:
// The value cannot have answered a document carrying `kind` is never backfilled, so // 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. // 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 /// 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 /// 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. /// 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 var name: String
repeat { repeat {
name = UUID().uuidString.lowercased() 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 /// 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 /// 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. /// 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, _ url: URL,
describedAs role: String, describedAs role: String,
operation: WriteOperation operation: WriteOperation
@@ -746,14 +752,19 @@ public enum BoardWriter: Sendable {
/// entries and symlinks already excluded) narrowed by `isUUIDShaped`, which is the loader's /// 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" /// level-detection rule and therefore the only definition of "an identity-bearing child"
/// this writer is allowed to have. /// 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)) ?? [] let candidates = (try? BoardLoader.directoryCandidates(in: folder)) ?? []
return candidates.filter { BoardLoader.isUUIDShaped($0.lastPathComponent) } return candidates.filter { BoardLoader.isUUIDShaped($0.lastPathComponent) }
} }
/// Renames a folder in place, keeping its parent the whole of an identity repair, and of a /// 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. /// 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, _ folder: URL,
toSiblingNamed name: String, toSiblingNamed name: String,
operation: WriteOperation operation: WriteOperation
@@ -777,7 +788,8 @@ public enum BoardWriter: Sendable {
/// away. The import boundary turns on this one comparison, so it is deliberately about /// 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, /// *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. /// 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 lhs.resolvingSymlinksInPath().standardizedFileURL.path
== rhs.resolvingSymlinksInPath().standardizedFileURL.path == rhs.resolvingSymlinksInPath().standardizedFileURL.path
} }
@@ -867,6 +879,12 @@ public enum BoardWriter: Sendable {
} }
do { 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] = [] var copied: [URL] = []
try remintDescendants(of: root, collecting: &copied, operation: operation) 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 /// 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 /// pointing this at a copied *board root* is literally that. A second implementation of "which
/// folders are identities" is exactly what must not exist. /// 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( static func remintDescendants(
of folder: URL, of folder: URL,
collecting copied: inout [URL], collecting copied: inout [URL],
@@ -916,6 +944,44 @@ public enum BoardWriter: Sendable {
copied.append(renamed) copied.append(renamed)
try remintDescendants(of: renamed, collecting: &copied, operation: operation) 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 /// **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. /// 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 /// **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 /// residue, the loader skips it too (`.missingIndex`), and there is no contract work to fail.
/// 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 /// **Comment folders are deliberately outside this preflight** (added 2026-07-30 with the comment
/// file nobody was going to touch. /// 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, /// **Internal rather than `private`**: template instantiation preflights its own tree with this,
/// for `remintDescendants`' reason one definition of what a copy owes its descendants. /// 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 /// Every **card or lane** beneath `folder`, depth first `remintDescendants`' recursion with the
/// the renaming taken out, so the preflight and the remint can never disagree about which folders /// renaming taken out, so the preflight and the remint cannot disagree about which folders a copy
/// a copy materializes as items. /// 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] { private static func identityDescendants(of folder: URL) -> [URL] {
var found: [URL] = [] var found: [URL] = []
for child in childCandidates(of: folder) { for child in childCandidates(of: folder) {
@@ -1023,11 +1098,37 @@ public enum BoardWriter: Sendable {
let indexURL = folder.appendingPathComponent(BoardLoader.indexFileName) let indexURL = folder.appendingPathComponent(BoardLoader.indexFileName)
guard FileManager.default.fileExists(atPath: indexURL.path) else { return } 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 try updateIndex(inItemFolder: folder, operation: operation) { document in
applyCopyContract(to: &document, stamps: stamps, now: now) 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 // MARK: - The materialized trash
/// `<boardRoot>/.trash/` the board's trash container, named but not created. /// `<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(itemFolder, describedAs: kind == .lane ? "lane folder" : "card folder", operation: operation)
try checkIsDirectory(boardRoot, describedAs: "board folder", operation: operation) try checkIsDirectory(boardRoot, describedAs: "board folder", operation: operation)
switch kind { 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 .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) 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 /// 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 /// 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. /// 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) try checkIsUUIDShaped(folder, operation: operation)
guard BoardLoader.isUUIDShaped(folder.deletingLastPathComponent().lastPathComponent) else { guard BoardLoader.isUUIDShaped(folder.deletingLastPathComponent().lastPathComponent) else {
throw BoardWriteError( 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 /// 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` /// rather than discarded so every failure after the pre-flight passes (the `FileManager`
/// move/copy itself, the post-arrival `updateIndex`) also names the item. /// 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, inItemFolder folder: URL,
operation: WriteOperation operation: WriteOperation
) throws(BoardWriteError) -> 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 /// 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 /// 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). /// 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 let data: Data
do { do {
data = try Data(contentsOf: url) 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, _ document: FrontmatterDocument,
at url: URL, at url: URL,
operation: WriteOperation 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. /// alert rather than the banner, so the Apply phrasing is never shown for one.
case rawSource(title: String?) 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 /// 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`/ /// on identity for the cases with no title slot at all: `createBoard`/`createLane`/
/// `createCard` are minting a file, not reading one; `importAttachment`, `removeAttachment` and /// `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 // `.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 // 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. // 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, case .createBoard, .createLane, .createCard, .importAttachment, .listAttachments,
.removeAttachment, .renumberChildren, .relocateLooseFile, .agentGuide, .removeAttachment, .renumberChildren, .relocateLooseFile, .agentGuide,
.displaceClaimedName, .repairDuplicateID: .displaceClaimedName, .repairDuplicateID, .saveCommentDraft, .postComment,
.editComment, .deleteComment, .purgeCommentTrash:
self self
case .move: .move(title: title) case .move: .move(title: title)
case .reorder: .reorder(title: title) case .reorder: .reorder(title: title)
@@ -2638,10 +2796,15 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible {
switch self { switch self {
case .reorder, .renumberChildren: case .reorder, .renumberChildren:
true 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, case .createBoard, .createLane, .createCard, .move, .copy, .delete, .purge, .migrateTombstone,
.style, .resize, .rename, .duplicateBoard, .saveAsTemplate, .paste, .importAttachment, .style, .resize, .rename, .duplicateBoard, .saveAsTemplate, .paste, .importAttachment,
.listAttachments, .removeAttachment, .relocateLooseFile, .agentGuide, .displaceClaimedName, .listAttachments, .removeAttachment, .relocateLooseFile, .agentGuide, .displaceClaimedName,
.repairDuplicateID, .toggleTask, .editBody, .rawSource: .repairDuplicateID, .toggleTask, .editBody, .rawSource, .saveCommentDraft, .postComment,
.editComment, .deleteComment, .purgeCommentTrash:
false false
} }
} }
@@ -2680,6 +2843,12 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible {
case let .toggleTask(title): Self.phrase("toggle a checkbox in", title) case let .toggleTask(title): Self.phrase("toggle a checkbox in", title)
case let .editBody(title): Self.phrase("save the body of", title) case let .editBody(title): Self.phrase("save the body of", title)
case let .rawSource(title): Self.phrase("apply source changes to", 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 remote = "remote"
public static let remoteState = "remote-state" 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> = [ public static let schemaOwned: Set<String> = [
schema, title, order, width, created, modified, modifiedBy, deleted, background, icon, schema, title, order, width, created, modified, modifiedBy, deleted, background, icon,
iconColor, kind, iconColor, kind,
+8
View File
@@ -95,6 +95,7 @@ extension FrontmatterDocument {
record(FrontmatterKeys.created, created) record(FrontmatterKeys.created, created)
record(FrontmatterKeys.modified, modified) record(FrontmatterKeys.modified, modified)
record(FrontmatterKeys.modifiedBy, modifiedBy) record(FrontmatterKeys.modifiedBy, modifiedBy)
record(FrontmatterKeys.author, author)
record(FrontmatterKeys.background, background) record(FrontmatterKeys.background, background)
record(FrontmatterKeys.icon, icon) record(FrontmatterKeys.icon, icon)
record(FrontmatterKeys.iconColor, iconColor) 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. /// 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) } 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, /// 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 /// 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 /// 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" public static let trashFolderName = ".trash"
/// A card's attachment folder (01-storage-format.md § Attachments) the one folder the app /// 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" public static let attachmentsFolderName = "attachments"
/// The file every level's content lives in. /// The file every level's content lives in.
public static let indexFileName = "index.md" 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 /// **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 /// card's own `index.md` plus the two reserved children.
/// reserves the name, not because anything writes it yet.
/// ///
/// **Compared lowercased**, because the filesystem this runs on usually is: a file spelled /// **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 /// `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/`. /// would hand the loose-file relocation a card's own content to move into `attachments/`.
public static let reservedCardChildNames: Set<String> = [ 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. /// 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, /// 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"). /// 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* /// **`comments` graduated with the feature** (the timing principle, run forwards): 01 called the
/// rather than a hedge 01 calls the reserved-but-unconsumed `comments` "the timing principle's own /// reserved-but-unconsumed name "the timing principle's own illustration" "a wrong-kind holder is
/// illustration": nothing reads that name until the tracker era, so a wrong-kind holder degrades no /// a tolerated stray today and joins the scheduled class **the day the name becomes load-bearing**".
/// behavior while it stands and stays a **tolerated stray** today, joining the scheduled class the /// That day is this one: a file or symlink wearing `comments` now breaks the card window's whole
/// day the name becomes load-bearing. `attachments`, by contrast, is load-bearing now: while a file /// thread no draft can be saved, no comment posted, nothing read which is exactly the
/// wears the name, every import into that card, every Finder drop on it, and the card window's /// "proactive when the defect is load-bearing now" condition (§ Validation and healing).
/// listing are broken which is exactly the "proactive when the defect is load-bearing now" /// `attachments` was load-bearing already and displaced from the start.
/// condition (§ Validation and healing).
/// ///
/// `index.md` is deliberately not here. It is not a *reserved child* the app protects from /// `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 /// 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. /// would mean the app deciding a folder's content is a squatter.
public static let claimedCardChildNames: [ClaimedName] = [ public static let claimedCardChildNames: [ClaimedName] = [
ClaimedName(name: attachmentsFolderName, expected: .directory, displacesSquatters: true), 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 /// 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 /// - 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). /// so the write lands wherever the board lives at heal time (`LooseCardFiles`' convention).
public static func squattedClaimedNames(inCardAt cardFolder: URL, path: String) -> [ClaimedNameSquatter] { 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, guard claimed.displacesSquatters,
let found = node(at: cardFolder.appendingPathComponent(claimed.name)), let found = node(at: folder.appendingPathComponent(claimed.name)),
found != claimed.expected found != claimed.expected
else { else {
return nil return nil
@@ -273,7 +345,7 @@ public enum IntegrityRules: Sendable {
name: claimed.name, name: claimed.name,
found: found, found: found,
expected: claimed.expected, expected: claimed.expected,
location: .card(path: path) location: location
) )
} }
} }
@@ -281,13 +353,14 @@ public enum IntegrityRules: Sendable {
// MARK: - Object kinds // MARK: - Object kinds
/// The kinds the schema knows (01-storage-format.md § Frontmatter Common to all levels, the /// 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 /// `kind` row). `comment` joined them with the enhanced schema's storage (§ Enhanced schema, the
/// lands an unrecognized value on disk is trusted as itself and never policed, so nothing here /// `kind: comment` field table) the first kind whose position is not a *level*: a comment lives
/// has to anticipate it. /// under a card's `comments/`, which is a reserved child rather than a depth.
public enum ObjectKind: String, Sendable, Equatable, CaseIterable { public enum ObjectKind: String, Sendable, Equatable, CaseIterable {
case board case board
case lane case lane
case card case card
case comment
} }
/// Where a folder's **position** places it "level is position" (01-storage-format.md /// Where a folder's **position** places it "level is position" (01-storage-format.md
@@ -298,6 +371,9 @@ public enum IntegrityRules: Sendable {
case card case card
/// Identity-shaped, under something that is neither a lane nor the trash a lane. /// Identity-shaped, under something that is neither a lane nor the trash a lane.
case 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 /// Inside `.trash/`, where the container is flat and **position cannot answer**: use
/// `trashKind(kindValue:hasIdentityShapedChildIndex:)`. /// `trashKind(kindValue:hasIdentityShapedChildIndex:)`.
case insideTrash 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 /// 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 /// live lane are both identity-shaped folders whose parent is not, and the container is the only
/// thing that tells them apart. /// 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 { public static func placement(ofFolderNamed name: String, inParentNamed parent: String) -> Placement {
if isIdentityShaped(parent) { return .card } if isIdentityShaped(parent) { return .card }
if parent.lowercased() == commentsFolderName { return .comment }
if parent.lowercased() == trashFolderName { return .insideTrash } if parent.lowercased() == trashFolderName { return .insideTrash }
if isIdentityShaped(name) { return .lane } if isIdentityShaped(name) { return .lane }
return .unknown 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 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 /// 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. /// 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 /// identity-shaped children with their own `index.md` lane (the key backfills on the next
/// touch), else card. /// touch), else card.
/// ///
/// `kind: board` in the trash is *not* a third answer: a board is not a thing that can be /// `kind: board` in the trash is *not* a third answer, and neither is `kind: comment`: neither is
/// trashed, so the value is unrecognized here and shape decides the same shrug an arbitrary /// a thing that can be in the *board's* trash (a deleted comment moves into its own thread's
/// string gets. /// `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 /// 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 /// 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:)) { switch kindValue.flatMap(ObjectKind.init(rawValue:)) {
case .lane: return .lane case .lane: return .lane
case .card: return .card 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 /// 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. /// 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 { public static func requiresOrder(_ kind: ObjectKind) -> Bool {
switch kind { switch kind {
case .board: false case .board, .comment: false
case .lane, .card: true 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. /// 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, /// It is a class here because the engine treats it exactly like the others same gates,
/// same memo, same clear-on-success. /// 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 { public enum Class: Sendable, Equatable, Hashable, CaseIterable {
case looseCardFiles case looseCardFiles
case legacyTombstone case legacyTombstone
case claimedNameSquatted case claimedNameSquatted
case duplicateIdentity case duplicateIdentity
case staleAgentGuide case staleAgentGuide
case commentTrashResidue
} }
/// The scheduled-heal class this defect belongs to, or **`nil` where there is no heal** the /// 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 { public enum Location: Sendable, Equatable {
case boardRoot case boardRoot
case card(path: String) 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`. /// The folder the claimed name lives in, under `root`.
public func folder(under root: URL) -> URL { public func folder(under root: URL) -> URL {
switch self { switch self {
case .boardRoot: root case .boardRoot:
case let .card(path): root.appendingPathComponent(path, isDirectory: true) 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 /// 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 { var signatureComponent: String {
switch self { switch self {
case .boardRoot: "" case .boardRoot: ""
case let .card(path): path + "/" case let .card(path), let .comment(path): path + "/"
case let .commentThread(cardPath): cardPath + "/" + IntegrityRules.commentsFolderName + "/"
} }
} }
} }
+12 -9
View File
@@ -349,15 +349,18 @@ struct CardClaimedNameTests {
#expect(result.claimedNameSquatters.map(\.found) == [.symlink]) #expect(result.claimedNameSquatters.map(\.found) == [.symlink])
} }
/// **`comments` stays tolerated** the timing principle, stated as the absence of a defect. /// **`comments` graduated** (2026-07-30, with the comment storage) the timing principle run
@Test("A file on a card's comments is not displaced — the name is not load-bearing yet") /// forwards: the name became load-bearing, so its squatter joined the scheduled class exactly as
func fileOnCommentsIsTolerated() throws { /// 01 said it would ("a wrong-kind holder is a tolerated stray today and joins the scheduled class
/// the day the name becomes load-bearing"). This test used to pin the absence of the defect.
@Test("A file on a card's comments is displaced now that the thread consumes the name")
func fileOnCommentsIsADefect() throws {
let fixture = try makeBoard() let fixture = try makeBoard()
defer { fixture.tearDown() } defer { fixture.tearDown() }
try fixture.file("\(Ident.lane1)/\(Ident.card1)/comments", Data("someday".utf8)) try fixture.file("\(Ident.lane1)/\(Ident.card1)/comments", Data("someday".utf8))
let result = try BoardLoader.load(boardRoot: fixture.root) let result = try BoardLoader.load(boardRoot: fixture.root)
#expect(result.claimedNameSquatters.isEmpty) #expect(result.claimedNameSquatters.map(\.name) == ["comments"])
#expect(result.looseCardFiles.isEmpty, "a reserved name is not a loose file either") #expect(result.looseCardFiles.isEmpty, "a reserved name is not a loose file either")
} }
@@ -447,14 +450,14 @@ struct CardClaimedNameTests {
#expect(try fixture.data("\(Ident.lane1)/\(Ident.card2)/attachments 2") == Data("two".utf8)) #expect(try fixture.data("\(Ident.lane1)/\(Ident.card2)/attachments 2") == Data("two".utf8))
} }
/// The table is the only thing to edit when `comments` graduates pinned so the split is a stated /// The table is the whole rule pinned so the level-uniform claim is stated rather than being an
/// rule rather than an accident of the probe's implementation. /// accident of the probe's implementation. Both card-level names displace since `comments`
@Test("The card-level table claims attachments and comments, and displaces only attachments") /// graduated with the comment storage (2026-07-30).
@Test("The card-level table claims attachments and comments, and both displace")
func theTableStatesTheSplit() { func theTableStatesTheSplit() {
let names = IntegrityRules.claimedCardChildNames let names = IntegrityRules.claimedCardChildNames
#expect(names.map(\.name) == ["attachments", "comments"]) #expect(names.map(\.name) == ["attachments", "comments"])
#expect(names.allSatisfy { $0.expected == .directory }) #expect(names.allSatisfy { $0.expected == .directory })
#expect(names.first { $0.name == "attachments" }?.displacesSquatters == true) #expect(names.allSatisfy { $0.displacesSquatters })
#expect(names.first { $0.name == "comments" }?.displacesSquatters == false)
} }
} }
+524
View File
@@ -0,0 +1,524 @@
import Foundation
import Testing
@testable import Kanban
/// The **read** half of the comment thread the window-scoped loader, the `kind: comment` field
/// table, the claimed names the thread adds, and the path-shape classification the announcer and the
/// composer will read (01-storage-format.md § Enhanced schema, storage specified 2026-07-29).
///
/// Everything here writes raw bytes and reads them back through the real loader, like every other
/// storage suite: `WriterFixture`, `Ident` and `Item` come from `WriterTestSupport.swift`.
// MARK: - Fixtures
/// Literal comment identities. Deliberately spanning cases and versions the identity predicate is
/// shape-only, and a thread must not sort or dedupe by an uppercase folder's ASCII accident.
enum CommentIdent {
static let one = "aaaaaaaa-0000-4000-8000-000000000001"
static let two = "bbbbbbbb-0000-4000-8000-000000000002"
static let three = "cccccccc-0000-4000-8000-000000000003"
static let upper = "DDDDDDDD-0000-4000-8000-000000000004"
static let seven = "eeeeeeee-0000-7000-8000-000000000005"
}
/// A comment's `index.md` with everything a write has to leave alone: an unknown key with an inline
/// comment, a reserved tracker key, a self-reported `author`, and a body.
func commentText(
author: String? = "Ada Lovelace",
created: String? = "2026-01-01T09:00:00Z",
modified: String? = "2026-01-01T09:00:00Z",
remote: String? = nil,
body: String = "A comment body — with *markdown*.\n"
) -> String {
var lines = ["---", "schema: 1"]
if let author { lines.append("author: \(author)") }
lines.append("project: lanework # agent overlay")
if let remote { lines.append("remote: \(remote)") }
if let created { lines.append("created: \(created)") }
if let modified { lines.append("modified: \(modified)") }
lines.append("kind: comment")
lines.append("---")
return lines.joined(separator: "\n") + "\n" + body
}
/// A one-lane, one-card board the smallest thing a thread can hang off.
@discardableResult
func makeCommentBoard(_ fixture: WriterFixture) throws -> String {
try fixture.item("", Item.board)
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "Fix login"))
return "\(Ident.lane1)/\(Ident.card1)"
}
func commentPath(_ id: String, inCard cardPath: String) -> String {
"\(cardPath)/comments/\(id)"
}
// MARK: - Loading
@Suite("Comments ▸ thread load")
struct CommentThreadLoadTests {
@Test("The thread is the UUID folders under comments/, and nothing else")
func loadsPostedCommentsOnly() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
let card = try makeCommentBoard(fixture)
try fixture.item(commentPath(CommentIdent.one, inCard: card), commentText())
try fixture.item("\(card)/comments/.draft", commentText(created: nil, modified: nil))
try fixture.item("\(card)/comments/.trash/\(CommentIdent.two)", commentText())
let thread = CommentThread.load(inCard: fixture.url(card), path: card)
#expect(thread.comments.map(\.id.rawValue) == [CommentIdent.one])
#expect(thread.hasDraft)
#expect(thread.strays.isEmpty)
}
@Test("A card with no comments/ answers empty, and that is not a defect")
func noThreadIsEmpty() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
let card = try makeCommentBoard(fixture)
let thread = CommentThread.load(inCard: fixture.url(card), path: card)
#expect(thread == .empty)
}
@Test("Chronology ascending is the order")
func sortsByCreatedAscending() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
let card = try makeCommentBoard(fixture)
// Written in an order that neither matches the dates nor the folder names, so neither can
// be the thing producing the answer by accident.
try fixture.item(commentPath(CommentIdent.three, inCard: card), commentText(created: "2026-03-03T09:00:00Z"))
try fixture.item(commentPath(CommentIdent.one, inCard: card), commentText(created: "2026-05-05T09:00:00Z"))
try fixture.item(commentPath(CommentIdent.two, inCard: card), commentText(created: "2026-01-01T09:00:00Z"))
let thread = CommentThread.load(inCard: fixture.url(card), path: card)
#expect(thread.comments.map(\.id.rawValue) == [CommentIdent.two, CommentIdent.three, CommentIdent.one])
}
@Test("Ties break by the canonical lowercase folder name")
func tiesBreakByCanonicalName() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
let card = try makeCommentBoard(fixture)
// `DDDDDDDD-` sorts before every lowercase name by raw ASCII and after `cccccccc-` by the
// canonical fold, which is the rule (01 § Ordering "comparing the canonical lowercase
// spelling never an uppercase folder's ASCII accident").
let stamp = "2026-02-02T09:00:00Z"
try fixture.item(commentPath(CommentIdent.upper, inCard: card), commentText(created: stamp))
try fixture.item(commentPath(CommentIdent.three, inCard: card), commentText(created: stamp))
let thread = CommentThread.load(inCard: fixture.url(card), path: card)
#expect(thread.comments.map(\.id.rawValue) == [CommentIdent.three, CommentIdent.upper])
}
@Test("A missing or malformed created sorts after every dated sibling, by name")
func undatedSortLast() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
let card = try makeCommentBoard(fixture)
try fixture.item(commentPath(CommentIdent.one, inCard: card), commentText(created: nil))
try fixture.item(commentPath(CommentIdent.upper, inCard: card), commentText(created: "[not, a, date]"))
// The one dated sibling, and deliberately the *latest* possible name, so a name-only sort
// would put it last and a date-first sort puts it first.
try fixture.item(commentPath(CommentIdent.seven, inCard: card), commentText(created: "2026-09-09T09:00:00Z"))
let thread = CommentThread.load(inCard: fixture.url(card), path: card)
#expect(thread.comments.map(\.id.rawValue) == [CommentIdent.seven, CommentIdent.one, CommentIdent.upper])
}
@Test("A coerce-tier fallback is logged as a defect, and changes nothing else")
func coercionsReported() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
let card = try makeCommentBoard(fixture)
try fixture.item(commentPath(CommentIdent.one, inCard: card), commentText(created: "[not, a, date]"))
let thread = CommentThread.load(inCard: fixture.url(card), path: card)
#expect(thread.comments.count == 1, "the comment still renders — coercion changes no behavior")
let coerced = thread.defects.compactMap { defect -> CoercedFrontmatter? in
if case let .coercedFrontmatter(work) = defect { work } else { nil }
}
#expect(coerced.count == 1)
#expect(coerced.first?.fields.map(\.key) == [FrontmatterKeys.created])
#expect(coerced.first?.path == "\(card)/comments/\(CommentIdent.one)/index.md")
}
@Test("A malformed author is a coerce-tier fallback too, and the field reads unattributed")
func authorCoerces() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
let card = try makeCommentBoard(fixture)
try fixture.item(commentPath(CommentIdent.one, inCard: card), commentText(author: "[a, b]"))
let thread = CommentThread.load(inCard: fixture.url(card), path: card)
#expect(thread.comments.first?.author.isMalformed == true)
#expect(thread.comments.first?.author.value == nil)
let keys = thread.defects.flatMap { defect -> [String] in
if case let .coercedFrontmatter(work) = defect { work.fields.map(\.key) } else { [] }
}
#expect(keys.contains(FrontmatterKeys.author))
}
@Test("Edited is modified differing from created, and nothing else")
func editedIndicator() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
let card = try makeCommentBoard(fixture)
try fixture.item(commentPath(CommentIdent.one, inCard: card), commentText())
try fixture.item(
commentPath(CommentIdent.two, inCard: card),
commentText(created: "2026-01-01T09:00:00Z", modified: "2026-02-02T09:00:00Z")
)
try fixture.item(commentPath(CommentIdent.three, inCard: card), commentText(created: nil, modified: nil))
let thread = CommentThread.load(inCard: fixture.url(card), path: card)
let edited = Dictionary(uniqueKeysWithValues: thread.comments.map { ($0.id.rawValue, $0.isEdited) })
#expect(edited[CommentIdent.one] == false)
#expect(edited[CommentIdent.two] == true)
#expect(edited[CommentIdent.three] == false, "a missing pair is not evidence of an edit")
}
@Test("Attachments list flat, in Finder order")
func attachmentsAreFinderOrdered() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
let card = try makeCommentBoard(fixture)
let path = commentPath(CommentIdent.one, inCard: card)
try fixture.item(path, commentText())
try fixture.file("\(path)/attachments/shot 10.png", Data("ten".utf8))
try fixture.file("\(path)/attachments/shot 2.png", Data("two".utf8))
try fixture.file("\(path)/attachments/nested/deep.png", Data("deep".utf8))
let thread = CommentThread.load(inCard: fixture.url(card), path: card)
#expect(thread.comments.first?.attachments == ["shot 2.png", "shot 10.png"])
}
}
// MARK: - Defect tolerance
@Suite("Comments ▸ defects never refuse")
struct CommentDefectToleranceTests {
/// Every shape that would have failed a *card* load outright, each beside one healthy sibling
/// so the claim is "the rest of the thread renders" and not merely "nothing threw".
@Test("A broken comment is a stray: skipped, logged, and the thread still renders")
func brokenCommentsAreStrays() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
let card = try makeCommentBoard(fixture)
try fixture.item(commentPath(CommentIdent.one, inCard: card), commentText())
// Unparseable YAML.
try fixture.item(commentPath(CommentIdent.two, inCard: card), "---\nschema: 1\n bad: [\n---\nbody\n")
// Not UTF-8.
try fixture.item(commentPath(CommentIdent.upper, inCard: card), bytes: Data([0xFF, 0xFE, 0x00]))
// Two-step-create residue: an identity-shaped folder with no index.md.
try FileManager.default.createDirectory(
at: fixture.url(commentPath(CommentIdent.seven, inCard: card)),
withIntermediateDirectories: true
)
// A stray folder that is not identity-shaped at all.
try fixture.item("\(card)/comments/notes", commentText())
let thread = CommentThread.load(inCard: fixture.url(card), path: card)
#expect(thread.comments.map(\.id.rawValue) == [CommentIdent.one])
#expect(thread.strays.count == 4)
#expect(thread.strays.contains { $0.name == "notes" && $0.reason == .notIdentityShaped })
#expect(thread.strays.contains { $0.name == CommentIdent.seven && $0.reason == .missingIndex })
// Every stray's bytes are still exactly where they were: tolerated is preserved-verbatim.
#expect(fixture.exists(commentPath(CommentIdent.two, inCard: card)))
#expect(fixture.exists("\(card)/comments/notes"))
}
@Test("A missing schema does not stop a comment's body from being unreadable-tolerated")
func schemaIsLenientOnAComment() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
let card = try makeCommentBoard(fixture)
try fixture.item(commentPath(CommentIdent.one, inCard: card), "---\nauthor: Ada\n---\nstill readable\n")
let thread = CommentThread.load(inCard: fixture.url(card), path: card)
#expect(thread.comments.count == 1, "a comment's schema is lenient, unlike every level's")
#expect(thread.comments.first?.schema.isMissing == true)
#expect(thread.comments.first?.body == "still readable\n")
}
@Test("A board carrying a broken comment still loads")
func theBoardIsUnaffected() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
let card = try makeCommentBoard(fixture)
try fixture.item(commentPath(CommentIdent.one, inCard: card), "---\nnot: [valid\n---\n")
let model = try fixture.snapshot()
#expect(model.lanes.first?.cards.count == 1)
}
}
// MARK: - The per-kind field table
@Suite("Comments ▸ the kind: comment field table")
struct CommentFieldTableTests {
@Test("schema is the only required field — never order, never title")
func requiredFields() throws {
#expect(IntegrityRules.requiresOrder(.comment) == false)
let withoutOrder = Data("---\nschema: 1\nkind: comment\n---\nbody\n".utf8)
#expect(throws: Never.self) {
try IntegrityRules.validateIndex(withoutOrder, path: "index.md", kind: .comment, supportedSchema: 1)
}
let withoutSchema = Data("---\nkind: comment\n---\nbody\n".utf8)
#expect(throws: BoardLoadError.self) {
try IntegrityRules.validateIndex(withoutSchema, path: "index.md", kind: .comment, supportedSchema: 1)
}
}
@Test("Position places a comment, and the URL form tells the two trashes apart")
func placement() {
let uuid = CommentIdent.one
#expect(IntegrityRules.placement(ofFolderNamed: uuid, inParentNamed: "comments") == .comment)
#expect(IntegrityRules.placement(ofFolderNamed: ".draft", inParentNamed: "comments") == .comment)
// The two-name form cannot tell a thread's `.trash` from the board's.
#expect(IntegrityRules.placement(ofFolderNamed: uuid, inParentNamed: ".trash") == .insideTrash)
let root = URL(fileURLWithPath: "/b.kanban")
let card = root.appendingPathComponent(Ident.lane1).appendingPathComponent(Ident.card1)
let thread = card.appendingPathComponent("comments")
#expect(IntegrityRules.placement(ofFolder: thread.appendingPathComponent(uuid)) == .comment)
#expect(
IntegrityRules.placement(ofFolder: thread.appendingPathComponent(".trash").appendingPathComponent(uuid))
== .comment,
"a comment in its thread's trash is still a comment"
)
#expect(
IntegrityRules.placement(ofFolder: root.appendingPathComponent(".trash").appendingPathComponent(uuid))
== .insideTrash,
"the board's trash is unchanged"
)
}
@Test("kind: comment is never a board-trash answer — shape decides there")
func trashKindIgnoresComment() {
#expect(IntegrityRules.trashKind(kindValue: "comment", hasIdentityShapedChildIndex: false) == .card)
#expect(IntegrityRules.trashKind(kindValue: "comment", hasIdentityShapedChildIndex: true) == .lane)
}
@Test("The on-touch backfill stamps kind: comment, never kind: lane")
func backfillIsComment() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
let card = try makeCommentBoard(fixture)
let path = commentPath(CommentIdent.one, inCard: card)
// A comment folder is identity-shaped under a non-identity-shaped parent shape-identical
// to a lane, which is exactly the mistake `Placement.comment` exists to prevent.
try fixture.item(path, "---\nschema: 1\nauthor: Ada\n---\nbody\n")
try BoardWriter.editComment(at: fixture.url(path), body: "edited\n", cardTitle: "Fix login")
let document = try FrontmatterDocument.parse(fixture.indexText(path))
#expect(document.kind.value == "comment")
}
@Test("author survives every app write; modified-by does not")
func authorSurvives() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
let card = try makeCommentBoard(fixture)
let path = commentPath(CommentIdent.one, inCard: card)
try fixture.item(path, "---\nschema: 1\nauthor: Ada Lovelace\nmodified-by: claude\nkind: comment\n---\nbody\n")
try BoardWriter.editComment(at: fixture.url(path), body: "edited\n", cardTitle: "Fix login")
let document = try FrontmatterDocument.parse(fixture.indexText(path))
#expect(document.author.value == "Ada Lovelace")
#expect(document.modifiedBy.isMissing, "modified-by is overlay; author is content")
}
@Test("author is an ordinary unknown key on every other kind — the Details section still shows it")
func authorIsNotSchemaOwnedElsewhere() throws {
let document = try FrontmatterDocument.parse("---\nschema: 1\norder: 1024\nauthor: Ada\n---\nbody\n")
#expect(document.unknownFields.map(\.key) == [FrontmatterKeys.author])
}
}
// MARK: - Claimed names
@Suite("Comments ▸ claimed names")
struct CommentClaimedNameTests {
@Test("comments graduated: a squatter at card level is now scheduled work")
func commentsDisplacesNow() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
let card = try makeCommentBoard(fixture)
try fixture.file("\(card)/comments", Data("squatter".utf8))
let result = try BoardLoader.load(boardRoot: fixture.root)
let squatters = result.defects.compactMap { defect -> ClaimedNameSquatter? in
if case let .claimedNameSquatted(work) = defect { work } else { nil }
}
#expect(squatters.count == 1)
#expect(squatters.first?.name == "comments")
#expect(squatters.first?.found == .file)
#expect(squatters.first?.location == .card(path: card))
}
@Test("The thread read reports .draft and .trash squatters, and a comment's own attachments")
func threadLevelSquatters() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
let card = try makeCommentBoard(fixture)
let path = commentPath(CommentIdent.one, inCard: card)
try fixture.item(path, commentText())
try fixture.file("\(card)/comments/.draft", Data("squatter".utf8))
try fixture.file("\(card)/comments/.trash", Data("squatter".utf8))
try fixture.file("\(path)/attachments", Data("squatter".utf8))
let thread = CommentThread.load(inCard: fixture.url(card), path: card)
let squatters = thread.defects.compactMap { defect -> ClaimedNameSquatter? in
if case let .claimedNameSquatted(work) = defect { work } else { nil }
}
#expect(Set(squatters.map(\.name)) == [".draft", ".trash", "attachments"])
#expect(squatters.contains { $0.location == .commentThread(cardPath: card) && $0.name == ".draft" })
#expect(squatters.contains { $0.location == .comment(path: path) && $0.name == "attachments" })
// The board walk stays out of it a thread is window-scoped.
let boardDefects = try BoardLoader.load(boardRoot: fixture.root).defects
#expect(!boardDefects.contains { if case .claimedNameSquatted = $0 { true } else { false } })
}
@Test("A file wearing comments makes the thread empty and the work the whole answer")
func squattedThreadFolder() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
let card = try makeCommentBoard(fixture)
try fixture.file("\(card)/comments", Data("squatter".utf8))
let thread = CommentThread.load(inCard: fixture.url(card), path: card)
#expect(thread.comments.isEmpty)
#expect(thread.defects.count == 1)
}
@Test("Each location displaces by the Finder ladder, and destroys nothing")
func displacementLadder() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
let card = try makeCommentBoard(fixture)
let path = commentPath(CommentIdent.one, inCard: card)
try fixture.item(path, commentText())
try fixture.file("\(card)/comments/.draft", Data("draft squatter".utf8))
try fixture.file("\(path)/attachments", Data("attachment squatter".utf8))
let thread = CommentThread.load(inCard: fixture.url(card), path: card)
for defect in thread.defects {
guard case let .claimedNameSquatted(squatter) = defect else { continue }
let freed = try BoardWriter.displaceClaimedName(squatter, atBoardRoot: fixture.root)
#expect(freed == "\(squatter.name) 2")
}
#expect(try fixture.data("\(card)/comments/.draft 2") == Data("draft squatter".utf8))
#expect(try fixture.data("\(path)/attachments 2") == Data("attachment squatter".utf8))
#expect(!fixture.exists("\(card)/comments/.draft"))
#expect(!fixture.exists("\(path)/attachments"))
}
@Test("A displacement that lost its race is success, not an error")
func displacementReVerifies() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
let card = try makeCommentBoard(fixture)
let squatter = ClaimedNameSquatter(
name: ".draft",
found: .file,
expected: .directory,
location: .commentThread(cardPath: card)
)
#expect(try BoardWriter.displaceClaimedName(squatter, atBoardRoot: fixture.root) == nil)
}
@Test("Two locations sign differently, so one heal's failure is not the other's")
func signaturesAreDistinct() {
let thread = IntegrityRules.Defect.claimedNameSquatted(ClaimedNameSquatter(
name: ".trash", found: .file, expected: .directory, location: .commentThread(cardPath: "l/c")
))
let root = IntegrityRules.Defect.claimedNameSquatted(ClaimedNameSquatter(
name: ".trash", found: .file, expected: .directory
))
#expect(root.signatures == ["claimed:.trash:file"], "the board root's spelling is unchanged")
#expect(thread.signatures == ["claimed:l/c/comments/.trash:file"])
}
}
// MARK: - Path shape
@Suite("Comments ▸ path shape")
struct CommentPathShapeTests {
private let lane = Ident.lane1
private let card = Ident.card1
@Test("A posted comment's path names its card and its identity")
func classifiesComment() {
let path = CommentPath.classify("\(lane)/\(card)/comments/\(CommentIdent.one)/index.md")
#expect(path?.cardPath == "\(lane)/\(card)")
#expect(path?.kind == .comment(ItemID(rawValue: CommentIdent.one)))
#expect(path?.id == ItemID(rawValue: CommentIdent.one))
}
@Test("The folder itself classifies, not only files inside it")
func classifiesTheFolder() {
#expect(CommentPath.classify("\(lane)/\(card)/comments/\(CommentIdent.one)")?.kind
== .comment(ItemID(rawValue: CommentIdent.one)))
}
@Test("The draft is its own shape, and has no identity")
func classifiesDraft() {
let path = CommentPath.classify("\(lane)/\(card)/comments/.draft/index.md")
#expect(path?.kind == .draft)
#expect(path?.id == nil)
}
@Test("A deleted comment classifies from the thread's own trash")
func classifiesTrashed() {
let path = CommentPath.classify("\(lane)/\(card)/comments/.trash/\(CommentIdent.two)/index.md")
#expect(path?.kind == .trashed(ItemID(rawValue: CommentIdent.two)))
}
@Test("A trashed card carries its thread, and the shape reads the same")
func classifiesUnderTheBoardTrash() {
let path = CommentPath.classify(".trash/\(card)/comments/\(CommentIdent.one)/index.md")
#expect(path?.cardPath == ".trash/\(card)")
#expect(path?.kind == .comment(ItemID(rawValue: CommentIdent.one)))
}
@Test("Everything else is somebody else's to describe")
func classifiesNothingElse() {
let cases = [
"\(lane)/\(card)/index.md",
"\(lane)/\(card)/attachments/shot.png",
"\(lane)/\(card)/comments",
"\(lane)/\(card)/comments/notes/index.md",
"\(lane)/\(card)/comments/.trash",
"\(lane)/\(card)/comments/.trash/notes",
"\(lane)/notacard/comments/\(CommentIdent.one)/index.md",
"CLAUDE.md",
"",
]
for path in cases {
#expect(CommentPath.classify(path) == nil, "'\(path)' should not classify")
}
}
}
+775
View File
@@ -0,0 +1,775 @@
import Foundation
import Testing
@testable import Kanban
/// The **write** half of the comment thread: the five Writer primitives, the two move-based inverses
/// registered at the store boundary, and the copy-boundary matrix (01-storage-format.md § Enhanced
/// schema; 05-card-window.md The comments column; 13-native-undo.md).
///
/// Assertions are against the **bytes on disk**, like every other write suite here a comment that
/// is merely equivalent in a model is not the claim. `WriterFixture`, `Ident` and `Item` come from
/// `WriterTestSupport.swift`; `CommentIdent`, `commentText` and `makeCommentBoard` from
/// `CommentThreadTests.swift`.
// MARK: - Helpers
private let cardTitle = "Fix login"
private func document(_ fixture: WriterFixture, _ relativePath: String) throws -> FrontmatterDocument {
try FrontmatterDocument.parse(fixture.indexText(relativePath))
}
/// The one comment folder in a thread, as a path the posted identity is minted, so a test that
/// posts has to find it rather than name it.
private func postedNames(_ fixture: WriterFixture, inCard card: String) throws -> [String] {
try fixture.entryNames("\(card)/comments")
.filter { IntegrityRules.isIdentityShaped($0) }
.sorted()
}
@MainActor
private func makeStore(_ fixture: WriterFixture) throws -> (store: BoardStore, history: NativeHistoryProvider) {
let store = try BoardStore(rootURL: fixture.root)
let history = NativeHistoryProvider()
store.history = history
return (store, history)
}
// MARK: - The draft
@Suite("Comments ▸ draft lifecycle")
struct CommentDraftTests {
@Test("The first save creates .draft with the comment schema, no title and no order")
func createStampsTheSchema() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
let card = try makeCommentBoard(fixture)
let outcome = try BoardWriter.saveCommentDraft(
inCard: fixture.url(card), body: "Half a thought", cardTitle: cardTitle
)
#expect(outcome == .created)
let draft = "\(card)/comments/.draft"
let text = try fixture.indexText(draft)
let parsed = try FrontmatterDocument.parse(text)
#expect(parsed.schema.value == 1)
#expect(parsed.kind.value == "comment")
#expect(parsed.author.value == NSFullUserName())
#expect(parsed.created.value != nil)
// One `Date` for both stamps, so a draft never reads as edited before it is posted.
#expect(parsed.created.value == parsed.modified.value)
#expect(parsed.title.isMissing, "a comment has no title")
#expect(parsed.order.isMissing, "a comment has no order")
#expect(parsed.body == "Half a thought")
#expect(text.hasPrefix("---\nschema: 1\n"))
}
@Test("An update replaces the body span and stamps modified; author and created stay")
func updateWritesTheBody() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
let card = try makeCommentBoard(fixture)
let draft = "\(card)/comments/.draft"
try fixture.item(draft, commentText())
let outcome = try BoardWriter.saveCommentDraft(
inCard: fixture.url(card), body: "Second thought\n", cardTitle: cardTitle
)
#expect(outcome == .updated)
let parsed = try document(fixture, draft)
#expect(parsed.body == "Second thought\n")
#expect(parsed.author.value == "Ada Lovelace")
#expect(parsed.created.value == parsed.created.value)
#expect(parsed.modified.value != parsed.created.value)
// The unknown key, its inline comment and its position are untouched.
#expect(try fixture.indexText(draft).contains("project: lanework # agent overlay"))
}
@Test("Identical bytes write nothing at all — the ~30 s tick must not stamp")
func unchangedWritesNothing() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
let card = try makeCommentBoard(fixture)
let draft = "\(card)/comments/.draft"
try fixture.item(draft, commentText(body: "Same\n"))
let before = try fixture.indexData(draft)
let stamp = try FileManager.default
.attributesOfItem(atPath: fixture.url(draft).appendingPathComponent("index.md").path)[.modificationDate] as? Date
let outcome = try BoardWriter.saveCommentDraft(
inCard: fixture.url(card), body: "Same\n", cardTitle: cardTitle
)
#expect(outcome == .unchanged)
#expect(try fixture.indexData(draft) == before)
let after = try FileManager.default
.attributesOfItem(atPath: fixture.url(draft).appendingPathComponent("index.md").path)[.modificationDate] as? Date
#expect(after == stamp)
}
@Test("A draft emptied of text with no attachments deletes its folder — never litter")
func emptiedDraftIsDeleted() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
let card = try makeCommentBoard(fixture)
try fixture.item("\(card)/comments/.draft", commentText(body: "typed\n"))
let outcome = try BoardWriter.saveCommentDraft(
inCard: fixture.url(card), body: " \n\n", cardTitle: cardTitle
)
#expect(outcome == .deleted)
#expect(!fixture.exists("\(card)/comments/.draft"))
#expect(fixture.exists("\(card)/comments"), "the container stays — the next draft mints into it")
}
@Test("A draft emptied of text but holding attachments survives")
func emptiedDraftWithAttachmentsSurvives() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
let card = try makeCommentBoard(fixture)
let draft = "\(card)/comments/.draft"
try fixture.item(draft, commentText(body: "typed\n"))
try fixture.file("\(draft)/attachments/shot.png", Data("png".utf8))
let outcome = try BoardWriter.saveCommentDraft(inCard: fixture.url(card), body: "", cardTitle: cardTitle)
#expect(outcome == .updated)
#expect(try document(fixture, draft).body == "")
#expect(fixture.exists("\(draft)/attachments/shot.png"))
}
@Test("An empty save against no draft creates nothing")
func emptySaveCreatesNothing() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
let card = try makeCommentBoard(fixture)
let outcome = try BoardWriter.saveCommentDraft(inCard: fixture.url(card), body: "", cardTitle: cardTitle)
#expect(outcome == .unchanged)
#expect(!fixture.exists("\(card)/comments"))
}
@Test("An interrupted create — a draft folder with no index.md — is filled in, not refused")
func indexlessDraftIsFilledIn() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
let card = try makeCommentBoard(fixture)
try FileManager.default.createDirectory(
at: fixture.url("\(card)/comments/.draft"), withIntermediateDirectories: true
)
#expect(try BoardWriter.saveCommentDraft(
inCard: fixture.url(card), body: "recovered\n", cardTitle: cardTitle
) == .created)
#expect(try document(fixture, "\(card)/comments/.draft").body == "recovered\n")
}
@Test("A draft is not a card write: only a card folder takes one")
func refusesNonCards() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try makeCommentBoard(fixture)
let failure = writeFailure {
try BoardWriter.saveCommentDraft(inCard: fixture.url(Ident.lane1), body: "x", cardTitle: nil)
}
#expect(failure?.operation == .saveCommentDraft(title: nil))
}
}
// MARK: - Posting
@Suite("Comments ▸ post")
struct CommentPostTests {
@Test("Posting renames the draft to a fresh lowercase identity and restamps both dates")
func postRenamesAndRestamps() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
let card = try makeCommentBoard(fixture)
let draft = "\(card)/comments/.draft"
try fixture.item(draft, commentText(created: "2020-01-01T09:00:00Z", modified: "2020-01-01T09:00:00Z"))
try fixture.file("\(draft)/attachments/shot.png", Data("png".utf8))
let draftBody = try document(fixture, draft).body
let posted = try BoardWriter.postComment(inCard: fixture.url(card), cardTitle: cardTitle)
#expect(!fixture.exists(draft))
#expect(posted.id.rawValue == posted.id.rawValue.lowercased())
#expect(IntegrityRules.isIdentityShaped(posted.id.rawValue))
#expect(try postedNames(fixture, inCard: card) == [posted.id.rawValue])
let path = "\(card)/comments/\(posted.id.rawValue)"
let parsed = try document(fixture, path)
#expect(parsed.created.value == parsed.modified.value, "post time is one instant, not two")
#expect(parsed.created.value.map { abs($0.timeIntervalSince(posted.posted)) < 1 } == true)
#expect(parsed.created.value != nil && parsed.created.value! > Date(timeIntervalSince1970: 1_700_000_000))
// Everything else is the draft's own bytes: the body, the author, the unknown key and its
// inline comment, and the attachment that rode the rename.
#expect(parsed.body == draftBody)
#expect(parsed.author.value == "Ada Lovelace")
#expect(try fixture.indexText(path).contains("project: lanework # agent overlay"))
#expect(fixture.exists("\(path)/attachments/shot.png"))
}
@Test("A posted comment joins the thread; the draft no longer does")
func postedCommentIsInTheThread() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
let card = try makeCommentBoard(fixture)
try fixture.item("\(card)/comments/.draft", commentText())
let posted = try BoardWriter.postComment(inCard: fixture.url(card), cardTitle: cardTitle)
let thread = CommentThread.load(inCard: fixture.url(card), path: card)
#expect(thread.comments.map(\.id) == [posted.id])
#expect(!thread.hasDraft)
}
@Test("Posting nothing refuses, naming the card")
func postWithoutADraftRefuses() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
let card = try makeCommentBoard(fixture)
let failure = writeFailure { _ = try BoardWriter.postComment(inCard: fixture.url(card), cardTitle: cardTitle) }
#expect(failure?.operation == .postComment(title: cardTitle))
}
@Test("A second post mints a second identity beside the first")
func twoPostsAreTwoComments() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
let card = try makeCommentBoard(fixture)
try fixture.item("\(card)/comments/.draft", commentText(body: "one\n"))
let first = try BoardWriter.postComment(inCard: fixture.url(card), cardTitle: cardTitle)
try fixture.item("\(card)/comments/.draft", commentText(body: "two\n"))
let second = try BoardWriter.postComment(inCard: fixture.url(card), cardTitle: cardTitle)
#expect(first.id != second.id)
#expect(try postedNames(fixture, inCard: card).count == 2)
}
}
// MARK: - Editing
@Suite("Comments ▸ edit")
struct CommentEditTests {
@Test("An edit writes the body and stamps modified — '· edited' falls out of the pair")
func editStampsModified() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
let card = try makeCommentBoard(fixture)
let path = commentPath(CommentIdent.one, inCard: card)
try fixture.item(path, commentText())
#expect(try BoardWriter.editComment(at: fixture.url(path), body: "revised\n", cardTitle: cardTitle))
let parsed = try document(fixture, path)
#expect(parsed.body == "revised\n")
#expect(parsed.created.value != parsed.modified.value)
let thread = CommentThread.load(inCard: fixture.url(card), path: card)
#expect(thread.comments.first?.isEdited == true)
}
@Test("An unchanged body writes nothing")
func unchangedEditWritesNothing() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
let card = try makeCommentBoard(fixture)
let path = commentPath(CommentIdent.one, inCard: card)
try fixture.item(path, commentText(body: "same\n"))
let before = try fixture.indexData(path)
#expect(try BoardWriter.editComment(at: fixture.url(path), body: "same\n", cardTitle: cardTitle) == false)
#expect(try fixture.indexData(path) == before)
}
@Test("Only a posted comment is editable — never the draft, never a trashed one")
func refusesTheTwoDotFolders() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
let card = try makeCommentBoard(fixture)
try fixture.item("\(card)/comments/.draft", commentText())
try fixture.item("\(card)/comments/.trash/\(CommentIdent.one)", commentText())
for path in ["\(card)/comments/.draft", "\(card)/comments/.trash/\(CommentIdent.one)"] {
let failure = writeFailure {
_ = try BoardWriter.editComment(at: fixture.url(path), body: "x", cardTitle: cardTitle)
}
#expect(failure?.operation == .editComment(title: cardTitle))
}
}
}
// MARK: - Delete, restore, purge
@Suite("Comments ▸ delete and purge")
struct CommentDeleteTests {
@Test("A delete is a move into comments/.trash that stamps and clears modified-by")
func deleteMovesAndStamps() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
let card = try makeCommentBoard(fixture)
let path = commentPath(CommentIdent.one, inCard: card)
try fixture.item(path, "---\nschema: 1\nauthor: Ada\nmodified-by: claude\ncreated: 2026-01-01T09:00:00Z\nmodified: 2026-01-01T09:00:00Z\nkind: comment\n---\nbody\n")
let id = try BoardWriter.deleteComment(at: fixture.url(path), cardTitle: cardTitle)
#expect(id.rawValue == CommentIdent.one)
#expect(!fixture.exists(path))
let trashed = "\(card)/comments/.trash/\(CommentIdent.one)"
let parsed = try document(fixture, trashed)
#expect(parsed.modified.value != parsed.created.value, "a container change stamps")
#expect(parsed.modifiedBy.isMissing)
#expect(parsed.author.value == "Ada", "author is content and survives even this")
#expect(parsed.body == "body\n")
}
@Test("A deleted comment leaves the thread")
func deletedIsExcluded() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
let card = try makeCommentBoard(fixture)
try fixture.item(commentPath(CommentIdent.one, inCard: card), commentText())
try fixture.item(commentPath(CommentIdent.two, inCard: card), commentText(created: "2026-02-02T09:00:00Z"))
try BoardWriter.deleteComment(at: fixture.url(commentPath(CommentIdent.one, inCard: card)), cardTitle: cardTitle)
let thread = CommentThread.load(inCard: fixture.url(card), path: card)
#expect(thread.comments.map(\.id.rawValue) == [CommentIdent.two])
}
@Test("Restoring is the ordinary move back out, and stamps again")
func restoreMovesBack() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
let card = try makeCommentBoard(fixture)
try fixture.item("\(card)/comments/.trash/\(CommentIdent.one)", commentText())
try BoardWriter.restoreComment(
ItemID(rawValue: CommentIdent.one), inCard: fixture.url(card), cardTitle: cardTitle
)
#expect(fixture.exists(commentPath(CommentIdent.one, inCard: card)))
#expect(!fixture.exists("\(card)/comments/.trash/\(CommentIdent.one)"))
#expect(try document(fixture, commentPath(CommentIdent.one, inCard: card)).body
== "A comment body — with *markdown*.\n")
}
@Test("The purge removes the entries, keeps strays, and leaves the container")
func purgeRemovesEntriesOnly() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
let card = try makeCommentBoard(fixture)
try fixture.item("\(card)/comments/.trash/\(CommentIdent.one)", commentText())
try fixture.item("\(card)/comments/.trash/\(CommentIdent.two)", commentText())
try fixture.file("\(card)/comments/.trash/notes.txt", Data("mine".utf8))
let purged = try BoardWriter.purgeCommentTrash(inCard: fixture.url(card))
#expect(Set(purged.map(\.rawValue)) == [CommentIdent.one, CommentIdent.two])
#expect(fixture.exists("\(card)/comments/.trash"))
#expect(try fixture.data("\(card)/comments/.trash/notes.txt") == Data("mine".utf8))
}
@Test("A card with no thread trash purges nothing")
func purgeIsTotal() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
let card = try makeCommentBoard(fixture)
#expect(try BoardWriter.purgeCommentTrash(inCard: fixture.url(card)).isEmpty)
}
}
// MARK: - The crash-residue memo
@MainActor
@Suite("Comments ▸ crash residue")
struct CommentResidueTests {
@Test("Residue left by a crashed session sweeps at the next open, and the memo clears")
func residueSweeps() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
let card = try makeCommentBoard(fixture)
try fixture.item("\(card)/comments/.trash/\(CommentIdent.one)", commentText())
let (store, _) = try makeStore(fixture)
store.sweepCommentTrashResidue(inCard: ItemID(rawValue: Ident.card1))
#expect(!fixture.exists("\(card)/comments/.trash/\(CommentIdent.one)"))
#expect(store.heals.memo(for: .commentTrashResidue) == nil, "cleared on success")
}
@Test("An open with nothing to sweep rests — no bracket, no memo")
func cleanOpenRests() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try makeCommentBoard(fixture)
let (store, _) = try makeStore(fixture)
store.sweepCommentTrashResidue(inCard: ItemID(rawValue: Ident.card1))
#expect(store.heals.memo(for: .commentTrashResidue) == nil)
}
@Test("The close purge and the residue sweep converge on the same disk state")
func purgeAndSweepAgree() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
let card = try makeCommentBoard(fixture)
try fixture.item("\(card)/comments/.trash/\(CommentIdent.one)", commentText())
let (store, _) = try makeStore(fixture)
store.purgeCommentTrash(inCard: ItemID(rawValue: Ident.card1))
#expect(try fixture.entryNames("\(card)/comments/.trash").isEmpty)
}
}
// MARK: - Undo
@MainActor
@Suite("Comments ▸ undo")
struct CommentUndoTests {
private func board() throws -> (fixture: WriterFixture, card: String) {
let fixture = try WriterFixture()
let card = try makeCommentBoard(fixture)
return (fixture, card)
}
@Test("Posting registers one step, named for the 06 verb family")
func postRegistersOneStep() throws {
let (fixture, card) = try board()
defer { fixture.tearDown() }
try fixture.item("\(card)/comments/.draft", commentText())
let (store, history) = try makeStore(fixture)
_ = store.postComment(inCard: ItemID(rawValue: Ident.card1))
#expect(history.canUndo)
#expect(history.undoActionName == "Comment")
}
@Test("Undoing a post renames back to .draft; redo replays the same identity and instant")
func postRoundTrip() throws {
let (fixture, card) = try board()
defer { fixture.tearDown() }
try fixture.item("\(card)/comments/.draft", commentText(body: "drafted\n"))
let (store, history) = try makeStore(fixture)
let posted = store.postComment(inCard: ItemID(rawValue: Ident.card1))
let id = try #require(posted)
let postedText = try fixture.indexText("\(card)/comments/\(id.rawValue)")
history.undo()
#expect(!fixture.exists("\(card)/comments/\(id.rawValue)"))
#expect(fixture.exists("\(card)/comments/.draft"))
// A rename stamps nothing: the un-posted draft carries the post's own bytes, untouched.
#expect(try fixture.indexText("\(card)/comments/.draft") == postedText)
history.redo()
#expect(try postedNames(fixture, inCard: card) == [id.rawValue], "the same identity, not a fresh mint")
#expect(try fixture.indexText("\(card)/comments/\(id.rawValue)") == postedText, "the same instant, restamped")
}
@Test("A .draft typed since makes the post's undo stale — it skips, it never clobbers")
func postUndoSkipsWhenADraftIsBack() throws {
let (fixture, card) = try board()
defer { fixture.tearDown() }
try fixture.item("\(card)/comments/.draft", commentText(body: "first\n"))
let (store, history) = try makeStore(fixture)
let id = try #require(store.postComment(inCard: ItemID(rawValue: Ident.card1)))
// A new draft, the way the composer would leave one.
try fixture.item("\(card)/comments/.draft", commentText(body: "second\n"))
let untouched = try fixture.indexText("\(card)/comments/.draft")
history.undo()
#expect(try fixture.indexText("\(card)/comments/.draft") == untouched, "the new draft is not overwritten")
#expect(fixture.exists("\(card)/comments/\(id.rawValue)"), "and the posted comment stays posted")
#expect(!history.canUndo, "the stale step was popped")
#expect(store.banners.signposts.map(\.message) == ["Undo skipped — '\(cardTitle)' changed outside Lanework"])
}
@Test("Deleting registers one step; undo moves it back and redo moves it in")
func deleteRoundTrip() throws {
let (fixture, card) = try board()
defer { fixture.tearDown() }
let path = commentPath(CommentIdent.one, inCard: card)
try fixture.item(path, commentText())
let (store, history) = try makeStore(fixture)
#expect(store.deleteComment(ItemID(rawValue: CommentIdent.one), inCard: ItemID(rawValue: Ident.card1)))
#expect(history.undoActionName == "Delete Comment")
#expect(fixture.exists("\(card)/comments/.trash/\(CommentIdent.one)"))
history.undo()
#expect(fixture.exists(path))
#expect(!fixture.exists("\(card)/comments/.trash/\(CommentIdent.one)"))
#expect(try document(fixture, path).body == "A comment body — with *markdown*.\n")
history.redo()
#expect(!fixture.exists(path))
#expect(fixture.exists("\(card)/comments/.trash/\(CommentIdent.one)"))
}
@Test("A purge leaves the delete step stale — it skips with a banner, resurrecting nothing")
func purgeMakesDeleteStepsStale() throws {
let (fixture, card) = try board()
defer { fixture.tearDown() }
try fixture.item(commentPath(CommentIdent.one, inCard: card), commentText())
let (store, history) = try makeStore(fixture)
#expect(store.deleteComment(ItemID(rawValue: CommentIdent.one), inCard: ItemID(rawValue: Ident.card1)))
store.purgeCommentTrash(inCard: ItemID(rawValue: Ident.card1))
#expect(history.canUndo, "invalidation is lazy — the stack still looks full")
history.undo()
#expect(!fixture.exists(commentPath(CommentIdent.one, inCard: card)))
#expect(!history.canUndo)
#expect(store.banners.signposts.map(\.message) == ["Undo skipped — '\(cardTitle)' changed outside Lanework"])
}
@Test("The draft save, the inline edit and the purge register nothing")
func theThreeSilentOperations() throws {
let (fixture, card) = try board()
defer { fixture.tearDown() }
try fixture.item(commentPath(CommentIdent.one, inCard: card), commentText())
try fixture.item("\(card)/comments/.trash/\(CommentIdent.two)", commentText())
let (store, history) = try makeStore(fixture)
let cardID = ItemID(rawValue: Ident.card1)
store.saveCommentDraft(inCard: cardID, body: "typing\n")
#expect(store.editComment(ItemID(rawValue: CommentIdent.one), inCard: cardID, body: "revised\n"))
store.purgeCommentTrash(inCard: cardID)
#expect(!history.canUndo, "no byte capture in any tier — 13's rule")
}
@Test("Comment expectations validate disk, which is why comments need no snapshot")
func expectationsReadDisk() throws {
let (fixture, card) = try board()
defer { fixture.tearDown() }
let path = commentPath(CommentIdent.one, inCard: card)
try fixture.item(path, commentText())
#expect(HistoryStaleness.isCurrent([.present(fixture.url(path))]))
#expect(HistoryStaleness.isCurrent([.absent(fixture.url("\(card)/comments/.draft"))]))
try FileManager.default.removeItem(at: fixture.url(path))
#expect(!HistoryStaleness.isCurrent([.present(fixture.url(path))]))
}
}
// MARK: - Copy boundaries
@Suite("Comments ▸ copy boundaries")
struct CommentCopyTests {
/// A card with a full thread: one posted comment carrying a tracker key and an attachment, a
/// draft, and one comment sitting in the thread's trash.
private func threadedCard(_ fixture: WriterFixture, in lane: String, card: String) throws -> String {
let path = "\(lane)/\(card)"
try fixture.item(path, Item.rich(order: "1024", title: "Fix login"))
try fixture.item("\(path)/comments/\(CommentIdent.one)", commentText(remote: "gitea#42"))
try fixture.file("\(path)/comments/\(CommentIdent.one)/attachments/shot.png", Data("png".utf8))
try fixture.item("\(path)/comments/.draft", commentText(body: "unposted\n"))
try fixture.item("\(path)/comments/.trash/\(CommentIdent.two)", commentText())
return path
}
@Test("An item-level copy carries the thread, remints it, severs remote, and strips the trash")
func itemCopyMatrix() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try fixture.item("", Item.board)
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
try fixture.item(Ident.lane2, Item.rich(order: "2048", title: "Doing"))
try threadedCard(fixture, in: Ident.lane1, card: Ident.card1)
let copy = try BoardWriter.copyItem(
at: fixture.url("\(Ident.lane1)/\(Ident.card1)"),
toParent: fixture.url(Ident.lane2),
order: 1024,
stamps: .fork
)
let copied = "\(Ident.lane2)/\(copy.rawValue)"
// The thread came, reminted.
let names = try postedNames(fixture, inCard: copied)
#expect(names.count == 1)
#expect(names[0] != CommentIdent.one, "comment folders remint like every copied folder")
#expect(names[0] == names[0].lowercased())
// The contract applied at comment depth.
let parsed = try document(fixture, "\(copied)/comments/\(names[0])")
#expect(parsed.value(for: FrontmatterKeys.remote) == nil, "the tracker claim is severed")
#expect(parsed.author.value == "Ada Lovelace")
#expect(parsed.created.value != nil, ".fork keeps created")
#expect(fixture.exists("\(copied)/comments/\(names[0])/attachments/shot.png"))
// The draft carries, fork-lossless; the trash does not travel.
#expect(try document(fixture, "\(copied)/comments/.draft").body == "unposted\n")
#expect(!fixture.exists("\(copied)/comments/.trash"))
// The source is untouched, thread trash included.
#expect(fixture.exists("\(Ident.lane1)/\(Ident.card1)/comments/.trash/\(CommentIdent.two)"))
#expect(fixture.exists("\(Ident.lane1)/\(Ident.card1)/comments/\(CommentIdent.one)"))
}
@Test("A copied lane's cards' threads are reminted too — arbitrary depth")
func laneCopyReachesCommentDepth() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try fixture.item("", Item.board)
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
try threadedCard(fixture, in: Ident.lane1, card: Ident.card1)
let copy = try BoardWriter.copyItem(
at: fixture.url(Ident.lane1), toParent: fixture.root, order: 4096, stamps: .fork
)
let cards = try fixture.entryNames(copy.rawValue).filter { IntegrityRules.isIdentityShaped($0) }
#expect(cards.count == 1)
let copiedCard = "\(copy.rawValue)/\(cards[0])"
let comments = try postedNames(fixture, inCard: copiedCard)
#expect(comments.count == 1)
#expect(comments[0] != CommentIdent.one)
#expect(try document(fixture, "\(copiedCard)/comments/\(comments[0])").value(for: FrontmatterKeys.remote) == nil)
#expect(!fixture.exists("\(copiedCard)/comments/.trash"))
}
@Test("A comment nobody can stamp never refuses the copy — it travels verbatim")
func brokenCommentDoesNotRefuseACopy() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try fixture.item("", Item.board)
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
try fixture.item(Ident.lane2, Item.rich(order: "2048", title: "Doing"))
try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "Fix login"))
// Readable, uneditable the shape that refuses a copy at card level.
try fixture.item("\(Ident.lane1)/\(Ident.card1)/comments/\(CommentIdent.one)", Item.uneditable)
let copy = try BoardWriter.copyItem(
at: fixture.url("\(Ident.lane1)/\(Ident.card1)"),
toParent: fixture.url(Ident.lane2),
order: 1024,
stamps: .fork
)
let copied = "\(Ident.lane2)/\(copy.rawValue)"
let names = try postedNames(fixture, inCard: copied)
#expect(names.count == 1, "the copy landed whole")
#expect(try fixture.indexText("\(copied)/comments/\(names[0])") == Item.uneditable, "verbatim")
}
@Test("Template instantiation is born-today at comment depth too")
func instantiationRestampsComments() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try fixture.item("", Item.board)
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
try fixture.item(Ident.lane2, Item.rich(order: "2048", title: "Doing"))
try threadedCard(fixture, in: Ident.lane1, card: Ident.card1)
let copy = try BoardWriter.copyItem(
at: fixture.url("\(Ident.lane1)/\(Ident.card1)"),
toParent: fixture.url(Ident.lane2),
order: 1024,
stamps: .born
)
let copied = "\(Ident.lane2)/\(copy.rawValue)"
let names = try postedNames(fixture, inCard: copied)
let created = try #require(document(fixture, "\(copied)/comments/\(names[0])").created.value)
#expect(created.timeIntervalSinceNow > -60, ".born restamps created")
}
@Test("A whole-board fork carries the thread verbatim — but never its trash")
func wholeBoardForkCarriesVerbatim() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try fixture.item("", Item.board)
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
try threadedCard(fixture, in: Ident.lane1, card: Ident.card1)
let destination = fixture.root.deletingLastPathComponent()
.appendingPathComponent("Fork-\(UUID().uuidString)", isDirectory: true)
defer { try? FileManager.default.removeItem(at: destination) }
try BoardTreeCopy.createDirectory(at: destination)
try BoardTreeCopy.copy(contentsOf: fixture.root, into: destination, isCancelled: { false })
let thread = destination
.appendingPathComponent(Ident.lane1).appendingPathComponent(Ident.card1)
.appendingPathComponent("comments")
// GUIDs kept, tracker keys kept a fork is a fork.
let forked = thread.appendingPathComponent(CommentIdent.one).appendingPathComponent("index.md")
#expect(FileManager.default.fileExists(atPath: forked.path))
let text = try String(decoding: Data(contentsOf: forked), as: UTF8.self)
#expect(text.contains("remote: gitea#42"))
// The draft too.
#expect(FileManager.default.fileExists(
atPath: thread.appendingPathComponent(".draft").appendingPathComponent("index.md").path
))
// The thread trash, never.
#expect(!FileManager.default.fileExists(atPath: thread.appendingPathComponent(".trash").path))
}
@Test("The board's own .trash is untouched by the comment strip")
func theBoardTrashIsNotTheThreadTrash() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try fixture.item("", Item.board)
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
try fixture.item(".trash/\(Ident.card4)", Item.rich(order: "1024", title: "Trashed"))
let destination = fixture.root.deletingLastPathComponent()
.appendingPathComponent("Fork-\(UUID().uuidString)", isDirectory: true)
defer { try? FileManager.default.removeItem(at: destination) }
try BoardTreeCopy.createDirectory(at: destination)
try BoardTreeCopy.copy(contentsOf: fixture.root, into: destination, isCancelled: { false })
#expect(FileManager.default.fileExists(
atPath: destination.appendingPathComponent(".trash/\(Ident.card4)/index.md").path
))
}
@Test("A trashed card carries its thread — and the trash interplay costs nothing")
func trashedCardsCarryTheirThread() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try fixture.item("", Item.board)
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
let card = try threadedCard(fixture, in: Ident.lane1, card: Ident.card1)
try BoardWriter.deleteCardToTrash(at: fixture.url(card), inBoard: fixture.root, order: 1024)
let trashedCard = ".trash/\(Ident.card1)"
#expect(fixture.exists("\(trashedCard)/comments/\(CommentIdent.one)"))
#expect(fixture.exists("\(trashedCard)/comments/.draft"))
#expect(fixture.exists("\(trashedCard)/comments/.trash/\(CommentIdent.two)"), "a move carries everything")
let thread = CommentThread.load(inCard: fixture.url(trashedCard), path: trashedCard)
#expect(thread.comments.map(\.id.rawValue) == [CommentIdent.one])
// Restoring is the ordinary move back, thread intact.
_ = try BoardWriter.moveItem(
at: fixture.url(trashedCard),
toParent: fixture.url(Ident.lane1),
sourceBoardRoot: fixture.root,
destinationBoardRoot: fixture.root,
order: 1024
)
#expect(fixture.exists("\(card)/comments/\(CommentIdent.one)"))
}
@Test("The purge takes the whole card, thread and all")
func purgeTakesTheThread() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try fixture.item("", Item.board)
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
try threadedCard(fixture, in: Ident.lane1, card: Ident.card1)
try fixture.moveFolder("\(Ident.lane1)/\(Ident.card1)", to: ".trash/\(Ident.card1)")
try BoardWriter.purgeTrashEntry(at: fixture.url(".trash/\(Ident.card1)"), inBoard: fixture.root)
#expect(!fixture.exists(".trash/\(Ident.card1)"))
}
}