Realign code with the 2026-07-31 rulings

The trash sorts by modified descending — the arrival rank mint retires
(Ranks.isOrderedForTrash one comparator, loader + merged order agree;
the legacy deleted: migration stamps modified from the tombstone
timestamp where parseable; delete undo steps validate existence-only;
agent guide v8). Trash selection goes kind-blind — ranges, marquee,
Select All, and the successor walk sweep both kinds; the guard moves to
the exits (mixed-payload drop refusal, copy/cut validation). The copy
stamping preflight widens back to comment depth (load-scoped posture —
the board always loads, the gesture refuses whole). Fixes a latent
no-op: trashed-lane drag restore never fired (DragSession.beginLanes
hard-coded the board container).

2403 tests in 413 suites green.

Claude-Session: https://claude.ai/code/session_01CqjXB7ASoWtbyoGod68k97
This commit is contained in:
2026-07-31 18:35:07 -04:00
parent 542ab169a3
commit bec75e4282
37 changed files with 1200 additions and 551 deletions
+100 -72
View File
@@ -94,9 +94,16 @@ public enum BoardWriter: Sendable {
/// `nil`, the default, derives it from position (`IntegrityRules.placement`), and stamps
/// nothing when position has no answer: a guessed kind on disk would be worse than an absent
/// one, because the trash's discriminator trusts what it finds.
/// - Parameter stampedAt: the value `modified` takes, for the **one** write whose stamp is not
/// "now": the legacy tombstone migration, which stamps from the `deleted:` timestamp it is
/// retiring so a board's real deletion order survives into the trash's `modified`-descending
/// sort (01-storage-format.md § Deletion). `nil`, the default, is `Date()` every other
/// caller. It is a parameter rather than something the `edits` closure could do because the
/// stamps deliberately run *after* `edits` and outrank it.
public static func updateIndex(
inItemFolder folder: URL,
kind: IntegrityRules.ObjectKind? = nil,
stampedAt: Date? = nil,
operation: WriteOperation,
edits: (inout FrontmatterDocument) -> Void
) throws(BoardWriteError) {
@@ -117,7 +124,7 @@ public enum BoardWriter: Sendable {
// the container's own arrangement and leaves both provenance keys exactly as it found them
// a standing `modified-by` survives a reorder, which is the pairing 01 spells out.
if !operation.rewritesOrderOnly {
document.set(FrontmatterKeys.modified, to: .date(Date()))
document.set(FrontmatterKeys.modified, to: .date(stampedAt ?? Date()))
document.remove(FrontmatterKeys.modifiedBy)
}
@@ -1036,15 +1043,20 @@ public enum BoardWriter: Sendable {
/// **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.
///
/// **Comment folders are deliberately outside this preflight** (added 2026-07-30 with the comment
/// 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`).
/// **The preflight reaches comment depth** (01-storage-format.md § Frontmatter and § Enhanced
/// schema, ruled 2026-07-31 reversing the 2026-07-30 carve-out that kept comments out of it):
/// "a comment whose frontmatter cannot take the stamp refuses the copy exactly like a card or
/// lane never-refuse is a *load* posture, and a user-initiated copy is a transaction, not a
/// load". The board still always loads with a broken annotation on it; the *gesture* may refuse,
/// and refusing is what keeps the sever rule true no copy carries a live `remote` claim or a
/// stale `modified-by` because one comment could not be rewritten.
///
/// The walk is therefore `copyContractDescendants`' every folder `remintDescendants` will
/// materialize and `stampCopiedDescendant` will then rewrite, comments included and the
/// refusal names the comment's *card*, which is what `operation.withTitle` produces here: the
/// enrichment reads the offending file's own title, and a comment has none, so the operation
/// keeps the copy root's. (The error always carries the path, which is what points at the
/// annotation itself.)
///
/// **Internal rather than `private`**: template instantiation preflights its own tree with this,
/// for `remintDescendants`' reason one definition of what a copy owes its descendants.
@@ -1052,7 +1064,7 @@ public enum BoardWriter: Sendable {
of folder: URL,
operation: WriteOperation
) throws(BoardWriteError) {
for descendant in identityDescendants(of: folder) {
for descendant in copyContractDescendants(of: folder) {
let indexURL = descendant.appendingPathComponent(BoardLoader.indexFileName)
guard FileManager.default.fileExists(atPath: indexURL.path) else { continue }
let document = try readDocument(at: indexURL, operation: operation)
@@ -1062,9 +1074,9 @@ public enum BoardWriter: Sendable {
/// Every **card or lane** beneath `folder`, depth first `remintDescendants`' recursion with the
/// renaming taken out, so the preflight and the remint cannot disagree about which folders a copy
/// 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.
/// materializes as *items*. Comment folders are not here (they hang off `comments/`, which is not
/// identity-shaped), which is exactly the right reach for `stripCommentTrash`, since a thread
/// lives under a card and nowhere else.
private static func identityDescendants(of folder: URL) -> [URL] {
var found: [URL] = []
for child in childCandidates(of: folder) {
@@ -1074,6 +1086,26 @@ public enum BoardWriter: Sendable {
return found
}
/// **Every folder below `folder` whose `index.md` the copy contract rewrites** the items *and*
/// their comments, which is `remintDescendants`' own reach read as a list.
///
/// The root's own thread is in it: `checkIndexIsRewritable` preflights the copy root itself, but
/// nothing preflights the comments hanging off it, and the copy stamps those too. Two walks that
/// disagreed about which folders a copy touches is exactly the drift a mid-flight failure after a
/// clean preflight would be.
private static func copyContractDescendants(of folder: URL) -> [URL] {
func comments(of item: URL) -> [URL] {
childCandidates(of: item.appendingPathComponent(
IntegrityRules.commentsFolderName, isDirectory: true))
}
var found = comments(of: folder)
for item in identityDescendants(of: folder) {
found.append(item)
found.append(contentsOf: comments(of: item))
}
return found
}
/// Stamps one copied folder below the root **strictly**, since the preflight has already cleared
/// the whole subtree (`checkCopiedDescendantsAreStampable`): an `index.md` that cannot be read or
/// edited here is a disk failure between the two reads, not a shape to tolerate, and it fails the
@@ -1087,6 +1119,12 @@ public enum BoardWriter: Sendable {
/// A folder with **no `index.md`** is still skipped, and for a different reason entirely: there is
/// nothing there to stamp (interrupted-create residue, which the loader skips too).
///
/// **A comment takes the same strict path as a card** (ruled 2026-07-31): the former lenient
/// branch stamp best-effort, log, copy verbatim on failure retired with the preflight's
/// comment carve-out, because the preflight now clears comments too and a failure here is a disk
/// failure at either depth. `kind` is left to position (`IntegrityRules.placement`), which
/// answers `.comment` for a folder under a card's `comments/`.
///
/// **Internal rather than `private`**, with `remintDescendants` and for its reason: an
/// instantiated board's lanes and cards are stamped by this exact rule.
static func stampCopiedDescendant(
@@ -1098,35 +1136,11 @@ public enum BoardWriter: Sendable {
let indexURL = folder.appendingPathComponent(BoardLoader.indexFileName)
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
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
@@ -1157,22 +1171,23 @@ public enum BoardWriter: Sendable {
/// meet it here.
/// 4. **Move the folder.** Nothing beneath it is read or rewritten, so `attachments/`, strays
/// and every byte arrive unchanged, exactly as in an ordinary move.
/// 5. **Rewrite `order` to `order`, and stamp.**
/// 5. **Stamp** and nothing else.
///
/// **`order` is the caller's, always** deliberately not defaulted and deliberately not
/// computed here. Entry is at the *top* ("every arrival lands at the trash's topmost
/// position, minting an `order` rank above the current top"), which is
/// `Ranks.insertAtHead(ofVisible:)` over the trash's current ranks a question about the
/// *snapshot*, which the store holds and this stateless layer does not. Value-passing keeps
/// the seam: the Writer takes a rank, the store computes it.
/// **No rank is minted, and `order` is not rewritten at all** (01-storage-format.md § Deletion,
/// re-ruled 2026-07-31, retiring the arrival rank mint; 03-board-ui.md § Trash): the trash sorts
/// by `modified` descending, "and that stamp *is* the position newest-first falls out with no
/// `order` rewrite at all; a delete is a pure folder move plus the stamp, and the item's `order`
/// key rides along untouched (which is also what undo's move-back restores for free)". The
/// parameter this call used to take is gone with the rule, and so is the store-side ladder that
/// computed it: there is no snapshot question left for the caller to answer.
///
/// **The `modified` stamp is the point, not a side effect** and it needs no exception to earn
/// it. The trash move changes the card's *container*, which is the whole predicate
/// **The `modified` stamp is therefore the whole write** and it needs no exception to earn it.
/// The trash move changes the card's *container*, which is the whole predicate
/// (`WriteOperation.rewritesOrderOnly`, refined 2026-07-30): "deletion is an edit to the item's
/// story", so it stamps exactly as a cross-lane move does, and the stamp is what a future
/// age-based auto-purge reads. It falls out of `updateIndex` here rather than being asked for,
/// which is why there is nothing extra in step 5 and why the reorders-don't-stamp rule needs no
/// trash carve-out to coexist with this call.
/// story", so it stamps exactly as a cross-lane move does. It falls out of `updateIndex` rather
/// than being asked for, which is why step 5 carries no argument and why the
/// reorders-don't-stamp rule needs no trash carve-out to coexist with this call. Its second job
/// is the `kind` the flat container needs, backfilled on the same touch.
///
/// **Collision inside `.trash/` is impossible by construction**, and it is checked anyway. The
/// card is a resident of this very board, and board-wide uniqueness now spans lanes *and* the
@@ -1186,14 +1201,12 @@ public enum BoardWriter: Sendable {
@discardableResult
public static func deleteCardToTrash(
at cardFolder: URL,
inBoard boardRoot: URL,
order: Double
inBoard boardRoot: URL
) throws(BoardWriteError) -> ItemID {
try moveIntoTrash(
at: cardFolder,
inBoard: boardRoot,
kind: .card,
order: order,
operation: .delete(title: nil),
removingLegacyKey: false
)
@@ -1208,12 +1221,12 @@ public enum BoardWriter: Sendable {
///
/// - **The guard is `checkIsLaneFolder`** UUID-shaped directly under a board root, which
/// refuses a card, a board root, a stray, and notably a folder already in `.trash/`.
/// - **`kind: lane` is stamped**, not derived. The rank rewrite is a `updateIndex` on a folder
/// that is by then *inside* `.trash/`, where position cannot answer and shape would answer
/// *wrongly* for the one lane that most needs the key: an **empty** lane is shape-identical to
/// a card (01's own "honest limit"). The caller knows what it moved, so it says so which is
/// also the backfill the ruling asks of this write ("`kind: lane` backfilled on touch when
/// absent the trash move's rank mint included").
/// - **`kind: lane` is stamped**, not derived. The stamping rewrite is an `updateIndex` on a
/// folder that is by then *inside* `.trash/`, where position cannot answer and shape would
/// answer *wrongly* for the one lane that most needs the key: an **empty** lane is
/// shape-identical to a card (01's own "honest limit"). The caller knows what it moved, so it
/// says so which is also the backfill the ruling asks of this write ("`kind: lane`
/// backfilled on touch when absent the trash move's `modified` stamp included").
///
/// **The subtree rides along untouched**: nothing beneath the lane is read or rewritten, so its
/// cards, their `attachments/` and every stray arrive byte-identical and come back with it on
@@ -1224,14 +1237,12 @@ public enum BoardWriter: Sendable {
@discardableResult
public static func deleteLaneToTrash(
at laneFolder: URL,
inBoard boardRoot: URL,
order: Double
inBoard boardRoot: URL
) throws(BoardWriteError) -> ItemID {
try moveIntoTrash(
at: laneFolder,
inBoard: boardRoot,
kind: .lane,
order: order,
operation: .delete(title: nil),
removingLegacyKey: false
)
@@ -1249,19 +1260,25 @@ public enum BoardWriter: Sendable {
/// The removal is `FrontmatterDocument.remove`, so it takes **every** occurrence of the key:
/// a hand-duplicated `deleted:` line cannot leave a twin behind that would re-migrate the card
/// on the next load. Nothing else in the file is touched unknown keys, comments, blank lines,
/// line endings and the body are the same bytes they were, and `order` and the stamps are the
/// only writes, exactly as for an ordinary delete.
/// line endings and the body are the same bytes they were, and the stamps are the only other
/// writes, exactly as for an ordinary delete.
///
/// **The stamp is the retiring key's own timestamp where it parses** (01-storage-format.md
/// § Deletion, re-ruled 2026-07-31: "the migration move stamps `modified` **from the legacy
/// `deleted:` timestamp** where parseable the deletion time is when the card entered the
/// trash, so real deletion order survives into the `modified`-descending sort and from
/// migration time otherwise"). It is the one write in the app whose `modified` is not "now",
/// and it is why the legacy value is read *before* the move rather than left to the closure:
/// `updateIndex`'s stamps deliberately outrank anything `edits` does.
@discardableResult
public static func migrateTombstonedCard(
at cardFolder: URL,
inBoard boardRoot: URL,
order: Double
inBoard boardRoot: URL
) throws(BoardWriteError) -> ItemID {
try moveIntoTrash(
at: cardFolder,
inBoard: boardRoot,
kind: .card,
order: order,
operation: .migrateTombstone(title: nil),
removingLegacyKey: true
)
@@ -1278,7 +1295,6 @@ public enum BoardWriter: Sendable {
at itemFolder: URL,
inBoard boardRoot: URL,
kind: IntegrityRules.ObjectKind,
order: Double,
operation initialOperation: WriteOperation,
removingLegacyKey: Bool
) throws(BoardWriteError) -> ItemID {
@@ -1293,7 +1309,17 @@ public enum BoardWriter: Sendable {
case .lane: try checkIsLaneFolder(itemFolder, operation: operation)
case .card, .board, .comment: try checkIsCardFolder(itemFolder, operation: operation)
}
operation = try checkIndexIsRewritable(inItemFolder: itemFolder, operation: operation)
// `checkIndexIsRewritable`'s two checks, spelled out rather than called, for one reason: the
// migration needs the *document* this read produces the legacy `deleted:` timestamp it is
// about to remove becomes the `modified` stamp (`migrateTombstonedCard`), and it has to be
// read while the file is still at its old path.
let sourceIndex = itemFolder.appendingPathComponent(BoardLoader.indexFileName)
let source = try readDocument(at: sourceIndex, operation: operation)
operation = operation.withTitle(source.title.value)
try checkEditable(source, at: sourceIndex, operation: operation)
// Parseable legacy stamp the deletion time it recorded; malformed, absent, or an ordinary
// delete `nil`, which is `updateIndex`'s "now".
let stamp = removingLegacyKey ? source.deleted.value : nil
let trash = trashFolder(inBoard: boardRoot)
do {
@@ -1322,8 +1348,10 @@ public enum BoardWriter: Sendable {
// absence where the item was, the shown-trash side sees an arrival where it went.
EchoLedger.current?.recordMove(from: itemFolder, to: arrived)
try updateIndex(inItemFolder: arrived, kind: kind, operation: operation) { document in
document.set(FrontmatterKeys.order, to: .double(order))
// The whole of the arrival write: the stamp (which is the row's position), the `kind` the
// flat container discriminates on, and for the migration alone the retiring key. `order`
// is deliberately absent; it rides along as the item left it.
try updateIndex(inItemFolder: arrived, kind: kind, stampedAt: stamp, operation: operation) { document in
if removingLegacyKey {
document.remove(FrontmatterKeys.deleted)
}