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