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
+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)))
}
}