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
+145 -51
View File
@@ -57,11 +57,12 @@ private func uuidName() -> String { UUID().uuidString.lowercased() }
@Suite("BoardLoader ▸ the .trash container")
struct TrashContainerLoadTests {
/// The container's whole ordering story: ordinary `order` ranks, ascending, sorted exactly as a
/// lane's cards are newest-first falls out of *minting* (each arrival takes a rank above the
/// current top), never out of a timestamp sort, so the loader has no trash-specific rule at all.
@Test("Trash cards load in rank order, in their own container, carrying no deleted key")
func trashCardsLoadInRankOrder() throws {
/// The container's whole ordering story: **`modified` descending** (01-storage-format.md
/// § Deletion, re-ruled 2026-07-31 the arrival rank mint retired). `order` rides along
/// untouched and is deliberately *not* consulted, which is what this fixture proves by giving the
/// three entries ranks that disagree with their stamps in every direction.
@Test("Trash cards load newest-first by `modified`, in their own container, carrying no deleted key")
func trashCardsLoadNewestFirstByModified() throws {
let fixture = try TrashFixture()
defer { fixture.tearDown() }
@@ -72,15 +73,23 @@ struct TrashContainerLoadTests {
try fixture.index("", "schema: 1\n")
try fixture.index(lane, "schema: 1\norder: 1024\n")
// Written oldest-first on disk; the ranks are what decides.
try fixture.index(".trash/\(oldest)", "schema: 1\norder: 1024\ntitle: Oldest\n")
try fixture.index(".trash/\(middle)", "schema: 1\norder: 0\ntitle: Middle\n")
try fixture.index(".trash/\(newest)", "schema: 1\norder: -1024\ntitle: Newest\n")
// The `order` values are scrambled against the stamps on purpose: were a rank still deciding
// anything, this fixture would read Newest, Middle, Oldest by accident of the old rule and
// the assertion would pass for the wrong reason.
try fixture.index(
".trash/\(oldest)", "schema: 1\norder: -1024\ntitle: Oldest\nmodified: 2026-07-01T09:00:00Z\n")
try fixture.index(
".trash/\(middle)", "schema: 1\norder: 1024\ntitle: Middle\nmodified: 2026-07-15T09:00:00Z\n")
try fixture.index(
".trash/\(newest)", "schema: 1\norder: 0\ntitle: Newest\nmodified: 2026-07-30T09:00:00Z\n")
let result = try BoardLoader.load(boardRoot: fixture.root)
#expect(result.model.trash.map(\.id.rawValue) == [newest, middle, oldest])
#expect(result.model.trash.map(\.title.value) == ["Newest", "Middle", "Oldest"])
// And the ranks really did ride along untouched the loader read them, it just did not sort
// by them.
#expect(result.model.trash.map(\.order) == [0, 1024, -1024])
// The pivot in one assertion: a trashed card carries no flag, it is simply somewhere else.
#expect(result.model.trash.allSatisfy { !$0.isDeleted })
#expect(result.model.lanes.map(\.id.rawValue) == [lane])
@@ -383,8 +392,7 @@ struct DeleteCardToTrashTests {
#expect(!fixture.exists(".trash"))
let id = try BoardWriter.deleteCardToTrash(
at: fixture.url("\(Ident.lane1)/\(Ident.card1)"),
inBoard: fixture.root,
order: -1024
inBoard: fixture.root
)
#expect(id == ItemID(rawValue: Ident.card1))
@@ -394,8 +402,11 @@ struct DeleteCardToTrashTests {
/// The move's whole edit: the new rank plus the stamps. `modified` is stamped **on purpose**
/// the one exception to moves-don't-stamp, and what a future age-based auto-purge reads.
@Test("Only order and the stamps are rewritten; every other byte survives")
func onlyOrderAndStampsChange() throws {
/// **The stamps are the *whole* rewrite** (01 § Deletion, re-ruled 2026-07-31): no rank is
/// minted on arrival, so `order` is one of the bytes that survives rather than one of the two
/// that change.
@Test("Only the stamps are rewritten; every other byte — `order` included — survives")
func onlyTheStampsChange() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
@@ -405,8 +416,7 @@ struct DeleteCardToTrashTests {
try BoardWriter.deleteCardToTrash(
at: fixture.url("\(Ident.lane1)/\(Ident.card1)"),
inBoard: fixture.root,
order: -1024
inBoard: fixture.root
)
let text = try fixture.indexText(".trash/\(Ident.card1)")
@@ -421,7 +431,7 @@ struct DeleteCardToTrashTests {
#expect(!text.contains("deleted:"))
let document = try FrontmatterDocument.parse(text)
#expect(document.order == .valid(-1024))
#expect(document.order == .valid(2048), "the rank rode along exactly as the card left its lane")
}
@Test("Attachments and strays travel byte-identical — nothing beneath the card is read")
@@ -439,8 +449,7 @@ struct DeleteCardToTrashTests {
try BoardWriter.deleteCardToTrash(
at: fixture.url("\(Ident.lane1)/\(Ident.card1)"),
inBoard: fixture.root,
order: -1024
inBoard: fixture.root
)
#expect(try fixture.data(".trash/\(Ident.card1)/attachments/shot.png") == png)
@@ -462,8 +471,7 @@ struct DeleteCardToTrashTests {
let error = writeFailure {
_ = try BoardWriter.deleteCardToTrash(
at: fixture.url(target),
inBoard: fixture.root,
order: -1024
inBoard: fixture.root
)
}
#expect(error != nil)
@@ -486,8 +494,7 @@ struct DeleteCardToTrashTests {
let error = writeFailure {
_ = try BoardWriter.deleteCardToTrash(
at: fixture.url("\(Ident.lane1)/\(Ident.card1)"),
inBoard: fixture.root,
order: -2048
inBoard: fixture.root
)
}
#expect(error?.operation == .delete(title: "Live"))
@@ -510,8 +517,7 @@ struct DeleteCardToTrashTests {
let error = writeFailure {
_ = try BoardWriter.deleteCardToTrash(
at: fixture.url("\(Ident.lane1)/\(Ident.card1)"),
inBoard: fixture.root,
order: -1024
inBoard: fixture.root
)
}
if case .uneditableFrontmatter = error?.reason {} else {
@@ -550,8 +556,7 @@ struct TombstoneMigrationTests {
try BoardWriter.migrateTombstonedCard(
at: fixture.url("\(Ident.lane1)/\(Ident.card1)"),
inBoard: fixture.root,
order: -1024
inBoard: fixture.root
)
#expect(!fixture.exists("\(Ident.lane1)/\(Ident.card1)"))
@@ -559,7 +564,7 @@ struct TombstoneMigrationTests {
#expect(!text.contains("deleted:"))
#expect(text.contains("project: lanework # agent overlay"))
#expect(text.contains("Body kept."))
#expect(try FrontmatterDocument.parse(text).order == .valid(-1024))
#expect(try FrontmatterDocument.parse(text).order == .valid(2048), "no rank is minted")
// And the board now loads it as an ordinary trash card.
let result = try BoardLoader.load(boardRoot: fixture.root)
@@ -567,6 +572,74 @@ struct TombstoneMigrationTests {
#expect(result.model.trash.map(\.id.rawValue) == [Ident.card1])
}
/// **The migration stamps `modified` from the key it is retiring** (01 § Deletion, re-ruled
/// 2026-07-31): "the deletion time is when the card entered the trash, so real deletion order
/// survives into the `modified`-descending sort". Without it every migrated card would land at
/// migration time and the board's deletion history would flatten into one instant.
///
/// The duplicate key is deliberate and its resolution is 01's own last-wins rule: the *final*
/// occurrence is the value, so 02-02 is the stamp and 01-01 is invisible.
@Test("The migration stamps `modified` from the legacy `deleted:` timestamp")
func migrationStampsFromTheLegacyTimestamp() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try fixture.item("", Item.board)
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Lane"))
try fixture.item("\(Ident.lane1)/\(Ident.card1)", """
---
schema: 1
title: Gone
order: 2048
modified: 2026-06-06T00:00:00Z
deleted: 2026-01-01T00:00:00Z
deleted: 2026-02-02T00:00:00Z
---
""")
try BoardWriter.migrateTombstonedCard(
at: fixture.url("\(Ident.lane1)/\(Ident.card1)"),
inBoard: fixture.root
)
let document = try FrontmatterDocument.parse(fixture.indexText(".trash/\(Ident.card1)"))
#expect(document.modified == .valid(Date(timeIntervalSince1970: 1_769_990_400)),
"2026-02-02T00:00:00Z — the last `deleted:` occurrence, not migration time")
}
/// The other half of the same sentence: "**and from migration time otherwise**". A stamp that
/// cannot be parsed is no evidence of when the card was deleted, so the honest answer is now
/// which lands it among the freshest rather than inventing a date.
@Test("An unparseable legacy timestamp falls back to migration time")
func migrationFallsBackToNow() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try fixture.item("", Item.board)
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Lane"))
try fixture.item("\(Ident.lane1)/\(Ident.card1)", """
---
schema: 1
title: Gone
order: 2048
modified: 2020-01-01T00:00:00Z
deleted: whenever
---
""")
let before = Date()
try BoardWriter.migrateTombstonedCard(
at: fixture.url("\(Ident.lane1)/\(Ident.card1)"),
inBoard: fixture.root
)
let document = try FrontmatterDocument.parse(fixture.indexText(".trash/\(Ident.card1)"))
let stamped = try #require(document.modified.value)
#expect(stamped >= before.addingTimeInterval(-1), "migration time, not the old `modified`")
// The key still goes presence is what migrates a card, validity is not (`stillTombstoned`).
#expect(!(try fixture.indexText(".trash/\(Ident.card1)").contains("deleted:")))
}
/// The lane half of this migration is **retired** (01 § Deletion, re-ruled 2026-07-29): there is
/// no `migrateTombstonedLane` to call, and the loader hands the store no lane work to do the
/// lane simply loads live with the key inert (`LegacyTombstoneDetectionTests`).
@@ -618,8 +691,7 @@ struct DeleteLaneToTrashTests {
let id = try BoardWriter.deleteLaneToTrash(
at: fixture.url(Ident.lane1),
inBoard: fixture.root,
order: -1024
inBoard: fixture.root
)
#expect(id == ItemID(rawValue: Ident.lane1), "a delete moves a folder, it does not rename one")
@@ -634,8 +706,8 @@ struct DeleteLaneToTrashTests {
/// The container-changing move stamps both provenance keys (01 § Frontmatter `modified`'s
/// scope, refined 2026-07-30) the same rule a card's trash move obeys.
@Test("The rank is rewritten, modified stamped and modified-by cleared")
func theRankRewriteStamps() throws {
@Test("`modified` is stamped and `modified-by` cleared, and the rank is left alone")
func theArrivalStamps() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
@@ -653,10 +725,10 @@ struct DeleteLaneToTrashTests {
""")
try BoardWriter.deleteLaneToTrash(at: fixture.url(Ident.lane1), inBoard: fixture.root, order: -1024)
try BoardWriter.deleteLaneToTrash(at: fixture.url(Ident.lane1), inBoard: fixture.root)
let document = try FrontmatterDocument.parse(fixture.indexText(".trash/\(Ident.lane1)"))
#expect(document.order == .valid(-1024))
#expect(document.order == .valid(2048), "the strip rank rides along; no trash rank is minted")
#expect(document.modifiedBy.isMissing)
#expect(document.modified.value.map { $0 > Date(timeIntervalSince1970: 1_600_000_000) } == true)
let text = try fixture.indexText(".trash/\(Ident.lane1)")
@@ -664,7 +736,7 @@ struct DeleteLaneToTrashTests {
#expect(text.contains("Lane notes."))
}
/// "`kind: lane` backfilled on touch when absent the trash move's rank mint included"
/// "`kind: lane` backfilled on touch when absent the trash move's `modified` stamp included"
/// (01 § Deletion). The **empty** lane is the case that needs it: in a flat container it is
/// shape-identical to a card, so a derived kind would answer wrongly and the row would come back
/// as a card.
@@ -676,7 +748,7 @@ struct DeleteLaneToTrashTests {
try fixture.item("", Item.board)
try fixture.item(Ident.lane1, "---\nschema: 1\ntitle: Empty\norder: 1024\n---\n")
try BoardWriter.deleteLaneToTrash(at: fixture.url(Ident.lane1), inBoard: fixture.root, order: -1024)
try BoardWriter.deleteLaneToTrash(at: fixture.url(Ident.lane1), inBoard: fixture.root)
#expect(try fixture.indexText(".trash/\(Ident.lane1)").contains("kind: lane"))
let result = try BoardLoader.load(boardRoot: fixture.root)
@@ -695,7 +767,7 @@ struct DeleteLaneToTrashTests {
try fixture.item("", Item.board)
try fixture.item(Ident.lane1, "---\nschema: 1\norder: 1024\nkind: lane\n---\n")
try BoardWriter.deleteLaneToTrash(at: fixture.url(Ident.lane1), inBoard: fixture.root, order: -1024)
try BoardWriter.deleteLaneToTrash(at: fixture.url(Ident.lane1), inBoard: fixture.root)
let text = try fixture.indexText(".trash/\(Ident.lane1)")
#expect(text.components(separatedBy: "kind: lane").count == 2, "written once, not twice")
@@ -719,8 +791,7 @@ struct DeleteLaneToTrashTests {
writeFailure {
try BoardWriter.deleteLaneToTrash(
at: fixture.url(target),
inBoard: fixture.root,
order: -1024
inBoard: fixture.root
)
} != nil
)
@@ -1038,11 +1109,11 @@ struct TrashKindDiscriminatorTests {
}
}
/// The column is one list interleaved by rank (03 § Trash), which the snapshot expresses as two
/// arrays carrying the ranks that interleave them so a consumer merging by `order` gets the
/// column, and neither array is "after" the other.
@Test("Both kinds carry the ranks that interleave them")
func kindsInterleaveByRank() throws {
/// The column is one list interleaved by **`modified` descending** (03 § Trash, re-ruled
/// 2026-07-31), which the snapshot expresses as two arrays each sorted by the same comparator
/// so `BoardModel.trashEntries`' merge is the column, and neither array is "after" the other.
@Test("Both kinds carry the stamps that interleave them")
func kindsInterleaveByStamp() throws {
let fixture = try TrashFixture()
defer { fixture.tearDown() }
let newestLane = uuidName()
@@ -1050,18 +1121,41 @@ struct TrashKindDiscriminatorTests {
let oldestLane = uuidName()
try fixture.index("", "schema: 1\n")
try fixture.index(".trash/\(oldestLane)", "schema: 1\norder: 3072\nkind: lane\n")
try fixture.index(".trash/\(middleCard)", "schema: 1\norder: 2048\n")
try fixture.index(".trash/\(newestLane)", "schema: 1\norder: 1024\nkind: lane\n")
try fixture.index(".trash/\(oldestLane)", "schema: 1\norder: 1024\nmodified: 2026-05-01T09:00:00Z\nkind: lane\n")
try fixture.index(".trash/\(middleCard)", "schema: 1\norder: 2048\nmodified: 2026-05-03T09:00:00Z\n")
try fixture.index(".trash/\(newestLane)", "schema: 1\norder: 3072\nmodified: 2026-05-05T09:00:00Z\nkind: lane\n")
let result = try BoardLoader.load(boardRoot: fixture.root)
#expect(result.model.trashedLanes.map(\.id.rawValue) == [newestLane, oldestLane])
#expect(result.model.trash.map(\.id.rawValue) == [middleCard])
let column = (result.model.trash.map { (order: $0.order, id: $0.id) }
+ result.model.trashedLanes.map { (order: $0.order, id: $0.id) })
.sorted { $0.order < $1.order }
.map(\.id.rawValue)
#expect(column == [newestLane, middleCard, oldestLane])
// The one merge, which every consumer of "the row below this one" reads.
#expect(result.model.trashEntries.map(\.id.rawValue) == [newestLane, middleCard, oldestLane])
}
/// The comparator's tail, and its undated rung neither is stated anywhere else, and both are
/// what keeps a hand-made or foreign-moved entry from deciding the column's top
/// (01 § Deletion: "Ties break by title (case-insensitive), then folder name").
@Test("Equal stamps fall to title then folder name, and an undated entry sorts last")
func theDeterministicTail() throws {
let fixture = try TrashFixture()
defer { fixture.tearDown() }
// Folder names in a fixed order, so the last rung is observable rather than incidental.
let alpha = "11111111-1111-4111-8111-111111111111"
let beta = "22222222-2222-4222-8222-222222222222"
let gamma = "33333333-3333-4333-8333-333333333333"
let undated = "44444444-4444-4444-8444-444444444444"
try fixture.index("", "schema: 1\n")
// Same instant, different titles: "apple" before "Banana" case-insensitively.
try fixture.index(".trash/\(beta)", "schema: 1\norder: 1024\ntitle: Banana\nmodified: 2026-05-05T09:00:00Z\n")
try fixture.index(".trash/\(gamma)", "schema: 1\norder: 1024\ntitle: apple\nmodified: 2026-05-05T09:00:00Z\n")
// Same instant *and* the same title: the folder name is the last word.
try fixture.index(".trash/\(alpha)", "schema: 1\norder: 1024\ntitle: apple\nmodified: 2026-05-05T09:00:00Z\n")
// No stamp at all a foreign mover that skipped the restamp sorts below every dated row.
try fixture.index(".trash/\(undated)", "schema: 1\norder: 1024\ntitle: Zulu\n")
let result = try BoardLoader.load(boardRoot: fixture.root)
#expect(result.model.trash.map(\.id.rawValue) == [alpha, gamma, beta, undated])
}
}