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
+156 -28
View File
@@ -110,21 +110,41 @@ public enum IntegrityRules: Sendable {
public static let trashFolderName = ".trash"
/// A card's attachment folder (01-storage-format.md § Attachments) the one folder the app
/// ever creates under a card.
/// ever creates under a card. **Fractal**: a comment folder has one too (§ Enhanced schema).
public static let attachmentsFolderName = "attachments"
/// The file every level's content lives in.
public static let indexFileName = "index.md"
/// A card's **comment thread** (01-storage-format.md § Enhanced schema) a plain reserved
/// child, never a level and never identity; the identities are the UUID folders inside it.
public static let commentsFolderName = "comments"
/// The card's single comment draft, inside `comments/` "a reserved dot-named folder holding
/// ordinary comment schema excluded from the thread listing" (§ Enhanced schema, ruled
/// 2026-07-29).
public static let commentDraftFolderName = ".draft"
/// The thread's own trash **the board's name one level down**, deliberately the same spelling:
/// "the materialized-trash pattern one level down, joining `.draft` in the claimed names"
/// (§ Enhanced schema). Undo's backing store, never a UI surface.
public static let commentTrashFolderName = trashFolderName
/// **The card-level reserved names** (01-storage-format.md § Fractal layout Rules): the
/// card's own `index.md` plus the two reserved children. `comments` is listed because the schema
/// reserves the name, not because anything writes it yet.
/// card's own `index.md` plus the two reserved children.
///
/// **Compared lowercased**, because the filesystem this runs on usually is: a file spelled
/// `Index.md` *is* the card's index to `fileExists`, and a case-sensitive reservation check
/// would hand the loose-file relocation a card's own content to move into `attachments/`.
public static let reservedCardChildNames: Set<String> = [
indexFileName, attachmentsFolderName, "comments",
indexFileName, attachmentsFolderName, commentsFolderName,
]
/// **The same table inside one comment folder** "a card's anatomy one level down, so the
/// fractal rules apply verbatim" (01-storage-format.md § Enhanced schema). A comment has no
/// `comments/` of its own: replies are deliberately deferred and flat is this iteration's rule.
public static let reservedCommentChildNames: Set<String> = [
indexFileName, attachmentsFolderName,
]
/// What kind of node a name is allowed to be.
@@ -188,14 +208,13 @@ public enum IntegrityRules: Sendable {
/// name) displaces by the same ladder (`attachments` `attachments 2`), so imports, Finder drops,
/// and the sidebar listing never fail one gesture at a time against a squatted name").
///
/// **`attachments` displaces; `comments` does not**, and the split is the *timing principle*
/// rather than a hedge 01 calls the reserved-but-unconsumed `comments` "the timing principle's own
/// illustration": nothing reads that name until the tracker era, so a wrong-kind holder degrades no
/// behavior while it stands and stays a **tolerated stray** today, joining the scheduled class the
/// day the name becomes load-bearing. `attachments`, by contrast, is load-bearing now: while a file
/// wears the name, every import into that card, every Finder drop on it, and the card window's
/// listing are broken which is exactly the "proactive when the defect is load-bearing now"
/// condition (§ Validation and healing).
/// **`comments` graduated with the feature** (the timing principle, run forwards): 01 called the
/// reserved-but-unconsumed name "the timing principle's own illustration" "a wrong-kind holder is
/// a tolerated stray today and joins the scheduled class **the day the name becomes load-bearing**".
/// That day is this one: a file or symlink wearing `comments` now breaks the card window's whole
/// thread no draft can be saved, no comment posted, nothing read which is exactly the
/// "proactive when the defect is load-bearing now" condition (§ Validation and healing).
/// `attachments` was load-bearing already and displaced from the start.
///
/// `index.md` is deliberately not here. It is not a *reserved child* the app protects from
/// squatters it is the card's content, and a directory named `index.md` makes the folder an
@@ -203,7 +222,26 @@ public enum IntegrityRules: Sendable {
/// would mean the app deciding a folder's content is a squatter.
public static let claimedCardChildNames: [ClaimedName] = [
ClaimedName(name: attachmentsFolderName, expected: .directory, displacesSquatters: true),
ClaimedName(name: "comments", expected: .directory, displacesSquatters: false),
ClaimedName(name: commentsFolderName, expected: .directory, displacesSquatters: true),
]
/// **The names claimed inside a card's `comments/`** the thread's two lifecycle folders
/// (01-storage-format.md § Enhanced schema: "`.draft` joins the claimed names (a wrong-kind node
/// squatting it displaces by the ladder)", and `comments/.trash/` "joining `.draft` in the claimed
/// names").
///
/// Both displace: a file wearing `.draft` makes the composer unsaveable and a file wearing
/// `.trash` makes every comment delete fail, so neither is latent while it stands.
public static let claimedCommentThreadNames: [ClaimedName] = [
ClaimedName(name: commentDraftFolderName, expected: .directory, displacesSquatters: true),
ClaimedName(name: commentTrashFolderName, expected: .directory, displacesSquatters: true),
]
/// **The card's table, one level down** a comment's own `attachments`, claimed exactly as a
/// card's is (§ Enhanced schema: "displacement of a squatted `attachments`" is named among the
/// fractal rules that "apply verbatim").
public static let claimedCommentChildNames: [ClaimedName] = [
ClaimedName(name: attachmentsFolderName, expected: .directory, displacesSquatters: true),
]
/// The claimed names as the lane walk needs them: lowercased, for a `contains` against a
@@ -262,9 +300,43 @@ public enum IntegrityRules: Sendable {
/// - Parameter path: the card folder's path **relative to the board root**, carried into the defect
/// so the write lands wherever the board lives at heal time (`LooseCardFiles`' convention).
public static func squattedClaimedNames(inCardAt cardFolder: URL, path: String) -> [ClaimedNameSquatter] {
claimedCardChildNames.compactMap { claimed in
squatters(among: claimedCardChildNames, in: cardFolder, at: .card(path: path))
}
/// The claimed-name defects inside one card's **`comments/`** `.draft` and `.trash`
/// (`claimedCommentThreadNames`, 01-storage-format.md § Enhanced schema).
///
/// **Window-scoped, unlike its card-level twin**: the board walk stays O(cards) and never opens a
/// thread (§ Enhanced schema "Comments are window-scoped, outside the board snapshot"), so this
/// is asked by the card window's own thread read, not by the loader.
///
/// - Parameter cardPath: the card folder's path relative to the board root, so the heal lands
/// wherever the board lives at write time (`LooseCardFiles`' convention).
public static func squattedClaimedNames(
inCommentThreadAt threadFolder: URL,
cardPath: String
) -> [ClaimedNameSquatter] {
squatters(among: claimedCommentThreadNames, in: threadFolder, at: .commentThread(cardPath: cardPath))
}
/// The claimed-name defects inside **one comment folder** its `attachments`, the card's rule
/// read one level down (`claimedCommentChildNames`). Window-scoped, for its sibling's reason.
///
/// - Parameter path: the comment folder's path relative to the board root.
public static func squattedClaimedNames(inCommentAt commentFolder: URL, path: String) -> [ClaimedNameSquatter] {
squatters(among: claimedCommentChildNames, in: commentFolder, at: .comment(path: path))
}
/// The shared body of the three plural detections: one table, one folder, one location. The board
/// root's singular answer stays its own, for the reason its doc comment gives.
private static func squatters(
among table: [ClaimedName],
in folder: URL,
at location: ClaimedNameSquatter.Location
) -> [ClaimedNameSquatter] {
table.compactMap { claimed in
guard claimed.displacesSquatters,
let found = node(at: cardFolder.appendingPathComponent(claimed.name)),
let found = node(at: folder.appendingPathComponent(claimed.name)),
found != claimed.expected
else {
return nil
@@ -273,7 +345,7 @@ public enum IntegrityRules: Sendable {
name: claimed.name,
found: found,
expected: claimed.expected,
location: .card(path: path)
location: location
)
}
}
@@ -281,13 +353,14 @@ public enum IntegrityRules: Sendable {
// MARK: - Object kinds
/// The kinds the schema knows (01-storage-format.md § Frontmatter Common to all levels, the
/// `kind` row). `comment` is reserved with the enhanced schema and deliberately absent until it
/// lands an unrecognized value on disk is trusted as itself and never policed, so nothing here
/// has to anticipate it.
/// `kind` row). `comment` joined them with the enhanced schema's storage (§ Enhanced schema, the
/// `kind: comment` field table) the first kind whose position is not a *level*: a comment lives
/// under a card's `comments/`, which is a reserved child rather than a depth.
public enum ObjectKind: String, Sendable, Equatable, CaseIterable {
case board
case lane
case card
case comment
}
/// Where a folder's **position** places it "level is position" (01-storage-format.md
@@ -298,6 +371,9 @@ public enum IntegrityRules: Sendable {
case card
/// Identity-shaped, under something that is neither a lane nor the trash a lane.
case lane
/// Under a card's `comments/` a posted comment, or the `.draft` that is one in every
/// respect but its name (01-storage-format.md § Enhanced schema: "ordinary comment schema").
case comment
/// Inside `.trash/`, where the container is flat and **position cannot answer**: use
/// `trashKind(kindValue:hasIdentityShapedChildIndex:)`.
case insideTrash
@@ -313,13 +389,37 @@ public enum IntegrityRules: Sendable {
/// The trash check sits *between* the two identity checks deliberately: a trashed card and a
/// live lane are both identity-shaped folders whose parent is not, and the container is the only
/// thing that tells them apart.
///
/// **The `comments/` check sits ahead of the trash's**, and ahead of the lane's for the reason
/// that matters: a posted comment is an identity-shaped folder whose parent is not identity-shaped
/// shape-identical to a lane so without it every comment would read as a lane and the on-touch
/// backfill would stamp `kind: lane` into a thread.
///
/// **One ambiguity two names cannot resolve**: `comments/.trash/<uuid>` answers `.insideTrash`
/// here, because `.trash` is the board's spelling one level down. `placement(ofFolder:)` is the
/// form that can tell them apart, and every caller holding a URL should use it.
public static func placement(ofFolderNamed name: String, inParentNamed parent: String) -> Placement {
if isIdentityShaped(parent) { return .card }
if parent.lowercased() == commentsFolderName { return .comment }
if parent.lowercased() == trashFolderName { return .insideTrash }
if isIdentityShaped(name) { return .lane }
return .unknown
}
/// The same rule read off a **URL** the only form that can tell the board's `.trash/` from a
/// comment thread's own one level down, since the two share a name and the two-name rule sees
/// only the name.
public static func placement(ofFolder url: URL) -> Placement {
let parent = url.deletingLastPathComponent()
let placement = placement(ofFolderNamed: url.lastPathComponent, inParentNamed: parent.lastPathComponent)
guard placement == .insideTrash,
parent.deletingLastPathComponent().lastPathComponent.lowercased() == commentsFolderName
else {
return placement
}
return .comment
}
/// **The trash's `kind` discriminator** (01-storage-format.md § Deletion, re-ruled 2026-07-29
/// the value-names-the-kind posture): depth defines meaning on the live board, but the trash is
/// flat, and an empty lane folder is shape-identical to a card folder.
@@ -330,9 +430,10 @@ public enum IntegrityRules: Sendable {
/// identity-shaped children with their own `index.md` lane (the key backfills on the next
/// touch), else card.
///
/// `kind: board` in the trash is *not* a third answer: a board is not a thing that can be
/// trashed, so the value is unrecognized here and shape decides the same shrug an arbitrary
/// string gets.
/// `kind: board` in the trash is *not* a third answer, and neither is `kind: comment`: neither is
/// a thing that can be in the *board's* trash (a deleted comment moves into its own thread's
/// `comments/.trash/`, 01-storage-format.md § Enhanced schema), so both values are unrecognized
/// here and shape decides the same shrug an arbitrary string gets.
///
/// The shape half is `@autoclosure` so that the rule stays a pure function of two facts while
/// its caller pays for the directory listing **only when the value did not answer** which on
@@ -344,7 +445,7 @@ public enum IntegrityRules: Sendable {
switch kindValue.flatMap(ObjectKind.init(rawValue:)) {
case .lane: return .lane
case .card: return .card
case .board, nil: return hasIdentityShapedChildIndex() ? .lane : .card
case .board, .comment, nil: return hasIdentityShapedChildIndex() ? .lane : .card
}
}
@@ -388,9 +489,14 @@ public enum IntegrityRules: Sendable {
/// Whether an object of `kind` must carry `order` the per-kind field table, as a rule rather
/// than as two hand-written call sites in the loader's walk.
///
/// **A comment carries none, and never gains one** (01-storage-format.md § Enhanced schema:
/// "**No `title`, no `order`**" "Ordering is chronology, not ranks", because a conversation's
/// semantics *are* chronology and tracker-synced comments carry independent clocks where minted
/// ranks would interleave arbitrarily).
public static func requiresOrder(_ kind: ObjectKind) -> Bool {
switch kind {
case .board: false
case .board, .comment: false
case .lane, .card: true
}
}
@@ -533,12 +639,20 @@ public enum IntegrityRules: Sendable {
/// read at the moment of healing (`AgentGuide.inspect`), not something a tree walk reports.
/// It is a class here because the engine treats it exactly like the others same gates,
/// same memo, same clear-on-success.
///
/// `commentTrashResidue` is the second such class, for the same reason one level down: the
/// residue is whatever a crashed session left in one card's `comments/.trash/`, read at the
/// moment the card window opens (01-storage-format.md § Enhanced schema "purged when the
/// card window closes; crash residue sweeps at the next card-window open, armed-then-cleared
/// like every heal memo"). The board walk never opens a thread, so no tree walk could report
/// it.
public enum Class: Sendable, Equatable, Hashable, CaseIterable {
case looseCardFiles
case legacyTombstone
case claimedNameSquatted
case duplicateIdentity
case staleAgentGuide
case commentTrashResidue
}
/// The scheduled-heal class this defect belongs to, or **`nil` where there is no heal** the
@@ -899,21 +1013,35 @@ public struct ClaimedNameSquatter: Sendable, Equatable {
public enum Location: Sendable, Equatable {
case boardRoot
case card(path: String)
/// A card's `comments/` container where `.draft` and `.trash` are claimed
/// (01-storage-format.md § Enhanced schema). `cardPath` names the *card*, so the one thing a
/// caller has to know is where the card is, exactly as `.card` asks.
case commentThread(cardPath: String)
/// One comment's own folder where `attachments` is claimed, the card's rule read one level
/// down. `path` is the comment folder's, root-relative.
case comment(path: String)
/// The folder the claimed name lives in, under `root`.
public func folder(under root: URL) -> URL {
switch self {
case .boardRoot: root
case let .card(path): root.appendingPathComponent(path, isDirectory: true)
case .boardRoot:
root
case let .card(path), let .comment(path):
root.appendingPathComponent(path, isDirectory: true)
case let .commentThread(cardPath):
root
.appendingPathComponent(cardPath, isDirectory: true)
.appendingPathComponent(IntegrityRules.commentsFolderName, isDirectory: true)
}
}
/// The location as a signature component `""` for the board root, so the existing root-level
/// signature spelling is unchanged and only a card-level defect adds a path segment.
/// signature spelling is unchanged and only a nested defect adds path segments.
var signatureComponent: String {
switch self {
case .boardRoot: ""
case let .card(path): path + "/"
case let .card(path), let .comment(path): path + "/"
case let .commentThread(cardPath): cardPath + "/" + IntegrityRules.commentsFolderName + "/"
}
}
}