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:
@@ -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 + "/"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user