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