Files
lanework/KanbanTests/RootRecoveryTests.swift
T
rzen 89d4d983e6 Wire the open-time writability probe and read-only lock
Closes the gap found at m10: enterUnwritableLock existed with zero call
sites. WritabilityProbe classifies the cause volume-first - a board on a
read-only DMG is also permission-denied by access(2), and "you don't
have permission" would send the user to a Get Info panel that cannot
help - with a pure classify(volumeIsReadOnly:isWritable:) truth table
and a two-syscall probe that rebuilds its URL to defeat NSURL resource
caching. ReadOnlyLockReason.unwritableLocation now carries the cause;
BannerCenter phrases the two ("this board's volume is read-only" vs
"you don't have permission to change this folder").

The probe wires once in BoardStoreRegistry.acquire, immediately after
the store loads - every open path funnels through it, and running
before the loose-file relocation and agent-guide hooks makes the
skipped-with-log guide write true by construction (its isWritableFile
pre-check demotes to second line of defense). The board still opens:
lock, not refusal.

The reconciling re-probe is now symmetric per 02's settled text - a
volume gone read-only mid-session raises the lock at the next probe
(sibling locks settle first, so a root returning read-only lands the
honest lock); the stale "deliberately one-way" comment and its pinning
test are gone. Save as Template's carve-out predicate extracted to a
testable allowsSave (behavior unchanged); Duplicate stays disabled.

11 tests added. 1649 green on both schemes.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
2026-07-29 12:34:55 -04:00

500 lines
25 KiB
Swift

import Foundation
import Testing
@testable import Kanban
/// What happens to an open board when its folder moves, disappears, or comes back — the settled
/// root-identity rules of 02-architecture.md § Write-failure surfacing, exercised end to end
/// against a **real FSEvents stream, a real registry, and a real bookmark**.
///
/// A fake would prove nothing here. The three claims under test are all claims about the
/// filesystem's actual behaviour: that a bookmark follows a rename, that FSEvents reports the
/// *creation* of a path it was watching before that path existed, and that a `access(2)` probe is
/// what distinguishes "this board loads fine" from "this board can be written to". Every one of
/// them would be assumed rather than tested against a double.
///
/// The flakiness that buys is handled the way `FolderWatcherTests` handles it: **waiting for
/// something is generous** (poll for seconds — a slow machine must not fail a correctness test)
/// and nothing is asserted from an elapsed interval. The one test that needs no filesystem timing
/// at all — the writability clearing rule — drives the store's inbound door directly instead, so
/// its ordering is exact rather than merely likely.
// MARK: - Support
/// `WriterFixture`, `Ident` and `Item` come from `WriterTestSupport.swift`.
/// A small board: two lanes, one card. Enough that a reload landing at a new root has something
/// recognisable in it.
@MainActor
private func makeBoard(at root: URL) throws {
let manager = FileManager.default
try manager.createDirectory(at: root, withIntermediateDirectories: true)
func write(_ relativePath: String, _ text: String) throws {
let folder = relativePath.isEmpty ? root : root.appendingPathComponent(relativePath, isDirectory: true)
try manager.createDirectory(at: folder, withIntermediateDirectories: true)
try Data(text.utf8).write(to: folder.appendingPathComponent("index.md"))
}
try write("", Item.board)
try write(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
try write("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "First"))
try write(Ident.lane2, Item.rich(order: "2048", title: "Doing"))
}
@MainActor
private func makeBoard() throws -> WriterFixture {
let fixture = try WriterFixture()
try makeBoard(at: fixture.root)
return fixture
}
/// Adds a card by hand — a *foreign* write by construction: no Writer, no bracket, exactly what an
/// agent or an editor does.
private func writeCard(inBoard root: URL, lane: String, id: String, title: String, order: String) throws {
let folder = root.appendingPathComponent(lane, isDirectory: true).appendingPathComponent(id, isDirectory: true)
try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
try Data(Item.rich(order: order, title: title).utf8).write(to: folder.appendingPathComponent("index.md"))
}
private func cardTitles(inLane id: String, of snapshot: BoardModel) -> [String] {
(snapshot.lanes.first { $0.id.rawValue == id }?.cards ?? []).compactMap(\.title.value)
}
/// The one shape a path comparison may take here. A bookmark resolves to the canonical location
/// (`/private/var/...`) while `FileManager.temporaryDirectory` hands out the symlinked one
/// (`/var/...`), so raw `URL` equality would fail on a board that relocated perfectly.
private func canonical(_ url: URL) -> String {
url.resolvingSymlinksInPath().standardizedFileURL.path
}
/// Polls until `condition` holds or the deadline passes — generous, because FSEvents delivery is
/// not a bounded-latency promise. A root change in particular can take several seconds to surface.
@MainActor
private func waitUntil(_ deadline: Duration = .seconds(15), _ condition: () -> Bool) async {
let start = ContinuousClock.now
while ContinuousClock.now - start < deadline {
if condition() { return }
try? await Task.sleep(for: .milliseconds(25))
}
}
/// Gives a freshly started stream a beat to register with `fseventsd`, so the first change a test
/// makes cannot land in the window between `FSEventStreamStart` and the stream actually being live.
@MainActor
private func settle() async {
try? await Task.sleep(for: .milliseconds(400))
}
// MARK: - Tests
@MainActor
@Suite("Root recovery")
struct RootRecoveryTests {
// MARK: Rename absorption
@Test("A rename is absorbed transparently: new root, no banner, no lock, wiring intact")
func renameIsAbsorbed() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let renamed = fixture.root
.deletingLastPathComponent()
.appendingPathComponent("renamed-\(UUID().uuidString)", isDirectory: true)
// Registered first so it runs *last*: the store is released — and its watcher stopped —
// before the folder it is watching is removed.
defer { try? FileManager.default.removeItem(at: renamed) }
let registry = BoardStoreRegistry()
let store = try registry.acquire(fixture.root)
defer { registry.release(store) }
await settle()
// A Finder rename, which 01-storage-format.md calls ordinary. The board is the *file*, not
// the string that names it.
try FileManager.default.moveItem(at: fixture.root, to: renamed)
// The done-when: the store's URLs re-derived, and a reload actually ran at the new root
// (`snapshot.rootURL` is the root the last successful walk used).
await waitUntil { canonical(store.snapshot.rootURL) == canonical(renamed) }
#expect(canonical(store.rootURL) == canonical(renamed))
#expect(canonical(store.snapshot.rootURL) == canonical(renamed))
// Nothing was ever wrong, and the strip says so.
#expect(store.readOnlyLock == nil)
#expect(store.reloadFailure == nil)
#expect(store.bannerRows.isEmpty)
#expect(store.snapshot.lanes.count == 2, "the board is still the board")
// The entry followed too: identity did not change, so the same store answers for the new
// path and a window opening it would share rather than duplicate.
#expect(registry.liveStore(for: renamed) === store)
#expect(registry.openBoardCount == 1)
// And the wiring survived the move: a foreign edit at the *new* path reloads.
try writeCard(inBoard: renamed, lane: Ident.lane1, id: Ident.card2, title: "Second", order: "2048")
await waitUntil { cardTitles(inLane: Ident.lane1, of: store.snapshot).contains("Second") }
#expect(cardTitles(inLane: Ident.lane1, of: store.snapshot) == ["First", "Second"])
#expect(store.bannerRows.isEmpty)
}
// MARK: Vanish and return
@Test("A vanished root locks the board read-only, and the root's return clears it")
func vanishAndReturn() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let registry = BoardStoreRegistry()
let store = try registry.acquire(fixture.root)
defer { registry.release(store) }
let lastGood = store.snapshot
await settle()
// The folder is deleted in Finder while the board is open. Bookmark re-resolution finds
// nothing that exists, and the last-known path is gone too.
try FileManager.default.removeItem(at: fixture.root)
await waitUntil { store.readOnlyLock == .vanishedRoot }
#expect(store.readOnlyLock == .vanishedRoot)
// Wait for the vanished state to *settle* before recreating anything, and not for tidiness:
// entering the lock re-attaches the watcher at the missing path, and that re-attach owes a
// debounced reconciling reload. Recreating the folder inside that 200 ms window would let
// the reload find the root already back and clear the lock without a root change ever being
// delivered — a legitimate recovery, but a different one from the one under test here.
// Waiting for that reload to land and fail pins the sequence to the real-world shape: the
// root comes back later, and its *creation* is what recovers the board.
await waitUntil { store.reloadFailure != nil }
await store.awaitQuiescence()
#expect(store.reloadFailure != nil, "the re-attach's reconciling reload fails against the missing root")
// The last-good snapshot is still on screen — that is the whole point of the lock.
#expect(store.snapshot == lastGood)
#expect(store.readOnlyLock == .vanishedRoot, "a failed reload never lifts the lock")
// And the strip leads with the lock. (The re-attach's reconciling reload fails against the
// missing root, so a breakage row stands behind it; the *lock* is what comes first.)
guard case .readOnlyLock(.vanishedRoot) = store.bannerRows.first else {
Issue.record("expected the lock row to lead, got \(store.bannerRows.map(\.id))")
return
}
// Every write is refused — nothing would land anywhere.
do {
try store.performWrite { () throws(BoardWriteError) -> Void in
try BoardWriter.updateIndex(inItemFolder: fixture.url(Ident.lane1), operation: .style(title: nil)) { _ in }
}
Issue.record("expected the locked board to refuse the write")
} catch let refusal as BoardStoreWriteRefusal {
#expect(refusal == .readOnlyLocked(.vanishedRoot))
}
#expect(store.banners.oneShots.isEmpty, "a refusal is not a failed write")
// The root returns: a Finder undo, a remount, a folder recreated where the board was. Built
// aside and moved into place in one step, so the path appears as a whole board rather than
// as a directory that is filled in over several reload debounces.
let staging = FileManager.default.temporaryDirectory
.appendingPathComponent("RootRecoveryTests-staging-\(UUID().uuidString)", isDirectory: true)
try makeBoard(at: staging)
try writeCard(inBoard: staging, lane: Ident.lane1, id: Ident.card3, title: "Third", order: "3072")
try FileManager.default.moveItem(at: staging, to: fixture.root)
// FSEvents was left watching the path precisely so this creation would be reported.
await waitUntil { store.readOnlyLock == nil }
#expect(store.readOnlyLock == nil, "a successful reload proves the root came back")
await waitUntil { cardTitles(inLane: Ident.lane1, of: store.snapshot).contains("Third") }
#expect(cardTitles(inLane: Ident.lane1, of: store.snapshot) == ["First", "Third"])
#expect(store.reloadFailure == nil)
#expect(store.bannerRows.isEmpty)
// The recreated folder is a different file than the one that was deleted, so the entry had
// to be re-keyed — otherwise the next window to open this board would get a second store
// over a board already on screen.
await waitUntil { registry.liveStore(for: fixture.root) === store }
#expect(registry.liveStore(for: fixture.root) === store)
#expect(registry.openBoardCount == 1)
// And writes are live again.
try store.performWrite { () throws(BoardWriteError) -> Void in
try BoardWriter.updateIndex(inItemFolder: fixture.url(Ident.lane1), operation: .style(title: nil)) { _ in }
}
#expect(store.banners.oneShots.isEmpty)
}
// MARK: The writability probe
@Test("The unwritable-location lock clears only on a reconciling reload whose probe passes")
func unwritableLockClearsOnlyOnAReconcilingProbe() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
// The probe has to be honest, so the root is made genuinely unwritable — `r-x`, which still
// reads perfectly. That is the whole difficulty of this case: the board loads fine.
try FileManager.default.setAttributes([.posixPermissions: 0o555], ofItemAtPath: fixture.root.path)
store.enterUnwritableLock(.permissionDenied)
#expect(store.readOnlyLock == .unwritableLocation(.permissionDenied))
// A foreign reload succeeds — and clears nothing. Loading proves nothing about writing,
// which is exactly why this lock's clearing rule is not the other two's.
store.handleWatcherEvent(.treeChanged(.foreign))
await store.awaitQuiescence()
#expect(store.reloadFailure == nil, "an unwritable root still reads")
#expect(store.readOnlyLock == .unwritableLocation(.permissionDenied))
// Neither does a reconciling one while the permission is still what it was.
store.handleWatcherEvent(.treeChanged(.reconciling))
await store.awaitQuiescence()
#expect(store.readOnlyLock == .unwritableLocation(.permissionDenied))
// The permission is fixed. Nothing announces that — a `chmod` in a terminal fires no event
// the board would act on — so the lock stands until the next reconciling sweep (wake, app
// activation) re-probes.
try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: fixture.root.path)
store.handleWatcherEvent(.treeChanged(.foreign))
await store.awaitQuiescence()
#expect(store.readOnlyLock == .unwritableLocation(.permissionDenied), "only a reconciling reload re-probes")
store.handleWatcherEvent(.treeChanged(.reconciling))
await store.awaitQuiescence()
#expect(store.readOnlyLock == nil, "a fixed permission clears the lock without ceremony")
#expect(store.bannerRows.isEmpty)
}
/// "The probe is symmetric (settled): … a volume gone read-only mid-session *raises* it at the
/// next probe — banner up front, not every gesture failing one at a time."
@Test("A reconciling reload that finds the root unwritable raises the lock")
func theProbeIsSymmetric() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
#expect(store.readOnlyLock == nil, "a writable board opens unlocked")
// The root goes read-only under the open board. Nothing announces it: a `chmod` in a
// terminal is not a tree change, and a mid-session remount is not one either.
try FileManager.default.setAttributes([.posixPermissions: 0o555], ofItemAtPath: fixture.root.path)
// A foreign reload is not a probe. Between probes, "a write that hits the newly read-only
// volume fails as an ordinary one-shot" — the condition is not yet standing.
store.handleWatcherEvent(.treeChanged(.foreign))
await store.awaitQuiescence()
#expect(store.reloadFailure == nil, "an unwritable root still reads")
#expect(store.readOnlyLock == nil, "only a reconciling reload probes")
// The next reconciliation converts the condition into the standing lock, cause and all.
store.handleWatcherEvent(.treeChanged(.reconciling))
await store.awaitQuiescence()
#expect(store.readOnlyLock == .unwritableLocation(.permissionDenied))
#expect(store.bannerRows.map(\.id) == ["read-only-lock"])
#expect(store.isReadOnly)
// And it is a *lock*, not a broken board: the snapshot is intact and reads stay live.
#expect(store.reloadFailure == nil)
#expect(cardTitles(inLane: Ident.lane1, of: store.snapshot) == ["First"])
// Symmetric in the other direction, from a lock this probe raised rather than one the open
// flow armed — the same rule, so the same clearing.
try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: fixture.root.path)
store.handleWatcherEvent(.treeChanged(.reconciling))
await store.awaitQuiescence()
#expect(store.readOnlyLock == nil, "a fixed permission clears the lock without ceremony")
#expect(store.bannerRows.isEmpty)
}
/// The raise is announced exactly once, in the banner's own words, through the single
/// per-reload sentence `land` posts — not a second voice of the probe's own.
@Test("A probe-raised lock speaks the banner's line, once")
func theRaiseIsAnnouncedOnce() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
var spoken: [String] = []
store.announce = { if let phrase = $0 { spoken.append(phrase) } }
try FileManager.default.setAttributes([.posixPermissions: 0o555], ofItemAtPath: fixture.root.path)
store.handleWatcherEvent(.treeChanged(.reconciling))
await store.awaitQuiescence()
#expect(spoken == ["Error: You don't have permission to change this folder — showing the last good view, read-only"])
// A second reconciling reload finds the same condition: the row is already standing, so
// nothing is said again.
store.handleWatcherEvent(.treeChanged(.reconciling))
await store.awaitQuiescence()
#expect(spoken.count == 1)
}
/// A vanished root that comes back on a read-only volume must not end up *unlocked*: the
/// sibling lock clears on the reload's success, and the probe in the same pass raises the honest
/// one. This drives the two halves directly, since a real remount is not a headless act.
@Test("A sibling lock clearing does not leave an unwritable root unlocked")
func aSiblingLockYieldsToTheProbe() async throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
store.enterVanishedRootLock()
#expect(store.readOnlyLock == .vanishedRoot)
try FileManager.default.setAttributes([.posixPermissions: 0o555], ofItemAtPath: fixture.root.path)
store.handleWatcherEvent(.treeChanged(.reconciling))
await store.awaitQuiescence()
#expect(store.readOnlyLock == .unwritableLocation(.permissionDenied))
}
// MARK: The probe's classification
/// The classifier is pure, so the row `access(2)` alone cannot distinguish — a read-only volume,
/// which fails `access(2)` exactly like a `r-x` folder does — is testable without a DMG.
@Test("A read-only volume outranks permission denial, whatever access(2) says")
func theVolumeAnswerWins() {
#expect(WritabilityProbe.classify(volumeIsReadOnly: true, isWritable: false) == .readOnlyVolume)
// The row that matters: on a mounted DMG both facts are true at once, and naming the folder
// would send the user to a Get Info panel that cannot help.
#expect(WritabilityProbe.classify(volumeIsReadOnly: true, isWritable: true) == .readOnlyVolume)
}
@Test("A writable volume leaves the folder to answer for itself")
func thePermissionAnswerIsTheFallback() {
#expect(WritabilityProbe.classify(volumeIsReadOnly: false, isWritable: false) == .permissionDenied)
#expect(WritabilityProbe.classify(volumeIsReadOnly: false, isWritable: true) == nil)
// A volume that will not answer degrades to access(2) — still honest about *whether*, and
// it describes the refusal as the folder's doing.
#expect(WritabilityProbe.classify(volumeIsReadOnly: nil, isWritable: false) == .permissionDenied)
#expect(WritabilityProbe.classify(volumeIsReadOnly: nil, isWritable: true) == nil)
}
@Test("The live probe reads the filesystem, not a cached resource value")
func theLiveProbeSeesChanges() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
#expect(WritabilityProbe.probe(fixture.root) == nil)
try FileManager.default.setAttributes([.posixPermissions: 0o555], ofItemAtPath: fixture.root.path)
#expect(WritabilityProbe.probe(fixture.root) == .permissionDenied)
try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: fixture.root.path)
#expect(WritabilityProbe.probe(fixture.root) == nil)
}
// MARK: The probe at open
/// "An unwritable board location enters the read-only lock at open" — through the registry,
/// which is the seam every open path funnels through.
@Test("Acquiring an unwritable board opens it, locked")
func openTimeProbeRaisesTheLock() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
try FileManager.default.setAttributes([.posixPermissions: 0o555], ofItemAtPath: fixture.root.path)
let registry = BoardStoreRegistry()
let store = try registry.acquire(fixture.root)
defer { registry.release(store) }
// The open **succeeded** — the lock is not a refusal, and viewing an archived board is the
// legitimate errand the read affordances exist for.
#expect(cardTitles(inLane: Ident.lane1, of: store.snapshot) == ["First"])
#expect(store.reloadFailure == nil)
// And the lock was up before anything could act on it.
#expect(store.readOnlyLock == .unwritableLocation(.permissionDenied))
#expect(store.bannerRows.map(\.id) == ["read-only-lock"])
// Writes are refused as a policy refusal, not as an I/O failure, and nothing is posted on
// top of the standing row.
#expect(throws: BoardStoreWriteRefusal.readOnlyLocked(.unwritableLocation(.permissionDenied))) {
try store.performWrite { () throws(BoardWriteError) -> Void in }
}
#expect(store.banners.oneShots.isEmpty)
}
/// "The open-time agent-guide write is skipped-with-log, the `CLAUDE.user.md`-taken precedent."
/// The lock is what skips it: `acquire` probes before it calls `refreshAgentGuide()`.
@Test("The open-time agent-guide write is skipped under the lock")
func openTimeGuideWriteIsSkipped() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
try FileManager.default.setAttributes([.posixPermissions: 0o555], ofItemAtPath: fixture.root.path)
let registry = BoardStoreRegistry()
let store = try registry.acquire(fixture.root)
defer { registry.release(store) }
#expect(!FileManager.default.fileExists(atPath: fixture.root.appendingPathComponent(AgentGuide.filename).path))
// Skipped with a log, never with a banner: the user did not ask for this file.
#expect(store.banners.oneShots.isEmpty)
#expect(store.bannerRows.map(\.id) == ["read-only-lock"])
}
@Test("A writable board acquires unlocked, and gets its guide")
func openTimeProbePassesQuietly() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let registry = BoardStoreRegistry()
let store = try registry.acquire(fixture.root)
defer { registry.release(store) }
#expect(store.readOnlyLock == nil)
#expect(store.bannerRows.isEmpty)
#expect(FileManager.default.fileExists(atPath: fixture.root.appendingPathComponent(AgentGuide.filename).path))
}
// MARK: The lock's scope, at this cause
/// The settled carve-out: "under the unwritable-location lock alone, Save as Template stays
/// live" — copy-out is a read — "unless an open Edit or raw-source session holds unsaved
/// content", and Duplicate stays disabled even there.
@Test("Save as Template survives this lock alone; the other two disable it")
func saveAsTemplateCarveOut() {
let live = SaveAsTemplateCommand.allowsSave(
lock: .unwritableLocation(.readOnlyVolume),
isEditingInline: false,
hasUnsavedCardContent: false
)
#expect(live, "archiving the read-only DMG board being inspected is a legitimate errand")
// The gate is the hazard, not the provenance: unsaved content the lock's suspended saves
// cannot flush would be silently missed by the template.
#expect(!SaveAsTemplateCommand.allowsSave(
lock: .unwritableLocation(.readOnlyVolume),
isEditingInline: false,
hasUnsavedCardContent: true
))
// Both causes are the same lock, so both carve out.
#expect(SaveAsTemplateCommand.allowsSave(
lock: .unwritableLocation(.permissionDenied),
isEditingInline: false,
hasUnsavedCardContent: false
))
// An open inline title editor holds a pending change no flush can reach.
#expect(!SaveAsTemplateCommand.allowsSave(
lock: .unwritableLocation(.permissionDenied),
isEditingInline: true,
hasUnsavedCardContent: false
))
// The other two locks disable it outright, unsaved content or not.
for lock: ReadOnlyLockReason in [.vanishedRoot, .bracketedReloadFailed] {
#expect(!SaveAsTemplateCommand.allowsSave(
lock: lock,
isEditingInline: false,
hasUnsavedCardContent: false
))
}
#expect(SaveAsTemplateCommand.allowsSave(lock: nil, isEditingInline: false, hasUnsavedCardContent: false))
}
/// Duplicate is `acceptsBoardMutations`, which is the bare lock — so it stays disabled under
/// this cause too ("its destination is the same unwritable parent").
@Test("Duplicate stays disabled under the unwritable-location lock")
func duplicateStaysDisabled() throws {
let fixture = try makeBoard()
defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root)
#expect(store.acceptsBoardMutations)
store.enterUnwritableLock(.readOnlyVolume)
#expect(!store.acceptsBoardMutations)
}
}