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
+15 -8
View File
@@ -63,8 +63,13 @@ enum AgentGuide {
/// `modified-by` like any content edit, the trash move included, since it's the same rule and
/// not a special case and the card-level `attachments` claimed name
/// (01-storage-format.md § Fractal layout Rules, "level-uniform"): that name belongs to the
/// app's own folder, so a *file* by that name is a defect the app displaces on sight.
static let version = 7
/// app's own folder, so a *file* by that name is a defect the app displaces on sight. **v8
/// retires the trash's arrival rank** (01-storage-format.md § Deletion and 03-board-ui.md
/// Trash, re-ruled 2026-07-31; 08-agent-integration.md's own line): the trash sorts by `modified`
/// descending, so there is no rank to mint on the way in the guide's smallest-`order`-minus-1024
/// formula is replaced by "restamp `modified`, leave `order` alone", which is the same stamp
/// discipline v7 already taught, now doing the ordering as well.
static let version = 8
// MARK: - The version marker
@@ -450,12 +455,14 @@ enum AgentGuide {
- **Delete a card or a lane = move its folder into `<board>/.trash/`**:
`mv <lane>/<card-uuid> <board>/.trash/`, or `mv <lane-uuid>
<board>/.trash/` for a whole lane (create `.trash/` if missing). A lane
travels with its cards inside it. Arrivals go on top: set the moved
item's `order` to the smallest `order` already in `.trash/` minus 1024
(empty trash: any number). It's a container change like any other move
(Moving and reordering above): stamp `modified` and re-stamp
`modified-by`. Restore is the same move in reverse — a card into a lane,
a lane back to board root, with a fresh `order`, stamped the same way.
travels with its cards inside it. It's a container change like any other
move (Moving and reordering above): stamp `modified` and re-stamp
`modified-by` — and here the stamp is also the position. **The trash
sorts by `modified`, newest first**, so a restamped arrival lands on top;
there is no rank to mint, and you should leave `order` exactly as it is —
it rides along for the restore. Restore is the same move in reverse — a
card into a lane, a lane back to board root, with a fresh `order`,
stamped the same way.
- **Stamp `kind: lane` when you trash a lane that lacks it.** `.trash/` is
flat, so an empty lane folder looks exactly like a card folder; the `kind`
value is what tells them apart in there.
+18 -8
View File
@@ -317,8 +317,10 @@ public enum BoardLoader: Sendable {
var trash: [Card] = []
var trashedLanes: [TrashedLane] = []
/// Every trash entry as the dedupe needs it, kind-blind the container is one flat list to
/// the identity rule, whatever the snapshot splits it into.
var trashEntries: [(id: ItemID, title: String?, order: Double)] = []
/// the identity rule, whatever the snapshot splits it into. Carried with the two keys the
/// container's order is stated in (`Ranks.isOrderedForTrash`), never `order`: the trash is
/// sorted by `modified` descending since 2026-07-31.
var trashEntries: [(id: ItemID, title: String?, modified: Date?)] = []
var trashKinds: [ItemID: IntegrityRules.ObjectKind] = [:]
for entryURL in trashCandidates(in: boardRoot) {
let entryName = entryURL.lastPathComponent
@@ -360,7 +362,7 @@ public enum BoardLoader: Sendable {
)
let id = ItemID(rawValue: entryName)
trashKinds[id] = kind
trashEntries.append((id: id, title: document.title.value, order: order))
trashEntries.append((id: id, title: document.title.value, modified: document.modified.value))
switch kind {
case .lane:
@@ -372,6 +374,7 @@ public enum BoardLoader: Sendable {
id: id,
schema: schema,
title: document.title,
modified: document.modified,
order: order,
heldCards: children().count,
document: document
@@ -407,12 +410,19 @@ public enum BoardLoader: Sendable {
// Display order is settled here rather than in the `BoardModel` call below, because the
// dedupe's last tie-break *is* traversal order and the rule needs the occurrences in it.
let orderedLanes = Ranks.sortedForDisplay(walkedLanes, order: \.order, name: \.name)
let orderedTrash = Ranks.sortedForDisplay(trash, order: \.order, name: { $0.id.rawValue })
let orderedTrashedLanes = Ranks.sortedForDisplay(trashedLanes, order: \.order, name: { $0.id.rawValue })
// The trash's own display order, **both kinds at once** the column interleaves them by rank
// **The trash's arrays are sorted by `modified` descending**, not by `order` (01-storage-format.md
// § Deletion, re-ruled 2026-07-31): the trash move rewrites no rank, so the stamp *is* the
// position. Each kind is sorted by the same comparator the merged `BoardModel.trashEntries`
// applies, which is what makes a kind-narrowed slice of the column agree with the column.
let orderedTrash = Ranks.sortedForTrash(
trash, modified: { $0.modified.value }, title: { $0.title.value }, name: { $0.id.rawValue })
let orderedTrashedLanes = Ranks.sortedForTrash(
trashedLanes, modified: { $0.modified.value }, title: { $0.title.value }, name: { $0.id.rawValue })
// The trash's own display order, **both kinds at once** the column interleaves them
// (03-board-ui.md § Trash), and the dedupe's last tie-break is stated in traversal order, so
// the two kinds are merged before the rule sees them rather than after.
let orderedTrashEntries = Ranks.sortedForDisplay(trashEntries, order: \.order, name: { $0.id.rawValue })
let orderedTrashEntries = Ranks.sortedForTrash(
trashEntries, modified: \.modified, title: \.title, name: { $0.id.rawValue })
let verdict = dedupeIdentities(
inBoardAt: boardRoot,
lanes: orderedLanes,
@@ -496,7 +506,7 @@ public enum BoardLoader: Sendable {
private static func dedupeIdentities(
inBoardAt root: URL,
lanes: [WalkedLane],
trash: [(id: ItemID, title: String?, order: Double)],
trash: [(id: ItemID, title: String?, modified: Date?)],
historyRanker: IdentityHistoryRanker?
) -> IntegrityRules.DedupeVerdict {
typealias Container = IntegrityRules.IdentityOccurrence.Container
+21 -12
View File
@@ -118,16 +118,17 @@ public struct BoardModel: Sendable, Equatable {
/// **The container's other kind is `trashedLanes`** (re-ruled 2026-07-29 lanes trash too).
/// The two are separate arrays rather than one list of a sum type because they are separate
/// *things*: a trashed card is an ordinary card that every card-shaped surface already reads,
/// and a trashed lane is an opaque row that none of them may. Interleaving the two by trash
/// rank is a rendering question (03-board-ui.md § Trash: "lane rows and cards interleave in the
/// one trash column purely by trash rank"), and both arrays carry the `order` that answers it.
/// and a trashed lane is an opaque row that none of them may. Interleaving the two is a
/// rendering question (03-board-ui.md § Trash: "lane rows and cards interleave in the one trash
/// column by `modified` descending"), and both arrays carry the stamp that answers it
/// `BoardModel.trashEntries` is the one merge.
///
/// **Display order is `order` ascending, like any lane's cards** `Ranks.sortedForDisplay`,
/// same folder-name tie-break. Newest-first falls out of ordinary ranks rather than a
/// timestamp sort: every arrival mints a rank *above* the current topmost
/// (`Ranks.insertAtHead`), so the trash needs no sort rule of its own. There is deliberately
/// no `deleted:` key on anything in here a trashed card is an ordinary card in a special
/// place.
/// **Display order is `modified` descending** `Ranks.sortedForTrash`, tie-broken by title then
/// folder name (re-ruled 2026-07-31, retiring the arrival rank mint). The trash is the one
/// container in the app whose sequence is not a rank sequence: the delete move stamps, and that
/// stamp *is* the position, with each entry's `order` riding along untouched for its restore.
/// There is deliberately no `deleted:` key on anything in here a trashed card is an ordinary
/// card in a special place.
///
/// Empty when `.trash/` is absent (the overwhelmingly common case the folder is minted by
/// the first delete), and empty when it holds nothing the loader recognizes as a card.
@@ -281,9 +282,17 @@ public struct TrashedLane: Identifiable, Sendable, Equatable {
public let schema: Int
public let title: FieldValue<String>
/// Rank within the trash, ascending = top to bottom the same required, strictly validated
/// field a live lane carries (`Lane.order`), and what interleaves this row among the trash's
/// cards. Newest-first falls out of it: every arrival mints a rank above the current topmost.
/// **When the row entered the trash** and therefore *where it sits*: the trash sorts by
/// `modified` descending (01-storage-format.md § Deletion, re-ruled 2026-07-31), and the trash
/// move is the container-changing write that stamps it. Missing on a foreign mover that skipped
/// the restamp, which sorts it below every dated sibling (`Ranks.isOrderedForTrash`).
public let modified: FieldValue<Date>
/// The lane's rank **among the board's lanes**, riding along untouched the trash move rewrites
/// no `order` at all, so this is still the strip position a restore would want and the value the
/// undo of a restore puts back. It is deliberately *not* what orders this row in the column
/// (`modified` is), and the same required, strictly validated field a live lane carries
/// (`Lane.order`).
public let order: Double
/// **How many cards the lane is holding** the row's whole other half ("Doing 5 cards").
+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)
}
+60
View File
@@ -143,6 +143,66 @@ enum Ranks: Sendable {
items.sorted { isOrderedForDisplay($0, before: $1, order: order, name: name) }
}
// MARK: - The trash's own order
/// The trash's order: **`modified` descending**, ties broken by title
/// (case-insensitive), then folder name (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 Ties break by title
/// (case-insensitive), then folder name the deterministic tail").
///
/// **`order` is not consulted at all in here**, which is the whole of the
/// ruling: a delete is a pure folder move plus the stamp, and the item's
/// `order` key rides along untouched for its eventual restore. The trash is
/// the one container in the app whose sequence is not a rank sequence.
///
/// **An undated entry sorts after every dated one** the comment thread's
/// own rule for a missing `created` ("ties and missing/malformed sort
/// after dated siblings"), read one container over: a stamp the app always
/// writes is missing only on a hand-made or foreign entry, and a missing
/// value must never outrank a real deletion. The tail then separates them
/// exactly as it separates two equal stamps.
///
/// Titles compare with `localizedCaseInsensitiveCompare`, the same
/// comparison the rest of the app's user-facing sorting uses, and an
/// untitled entry compares as the empty string it sorts first among its
/// stamp-mates, which is deterministic and is all the tail owes.
static func isOrderedForTrash<T>(
_ lhs: T, before rhs: T,
modified: (T) -> Date?, title: (T) -> String?, name: (T) -> String
) -> Bool {
let lhsModified = modified(lhs)
let rhsModified = modified(rhs)
if lhsModified != rhsModified {
// Newest first; an absent stamp is older than every present one.
switch (lhsModified, rhsModified) {
case let (.some(left), .some(right)): return left > right
case (.some, .none): return true
case (.none, .some): return false
case (.none, .none): break
}
}
let lhsTitle = title(lhs) ?? ""
let rhsTitle = title(rhs) ?? ""
let comparison = lhsTitle.localizedCaseInsensitiveCompare(rhsTitle)
guard comparison == .orderedSame else { return comparison == .orderedAscending }
return name(lhs) < name(rhs)
}
/// Sorts the trash's entries into column order `isOrderedForTrash`'s rule,
/// applied by the loader to each kind's array and by `BoardModel.trashEntries`
/// to the merged sequence, so the two can never disagree about what "the row
/// below this one" is (03-board-ui.md § Trash: "The merged order is one
/// derivation").
static func sortedForTrash<T>(
_ items: [T],
modified: (T) -> Date?,
title: (T) -> String?,
name: (T) -> String
) -> [T] {
items.sorted { isOrderedForTrash($0, before: $1, modified: modified, title: title, name: name) }
}
// MARK: - Tombstone exclusion
/// `append(toVisible:)`, ignoring tombstoned siblings. Tombstones are