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
This commit is contained in:
2026-07-29 12:34:55 -04:00
parent 5880838e66
commit 89d4d983e6
11 changed files with 499 additions and 71 deletions
+26 -3
View File
@@ -339,12 +339,35 @@ struct SaveAsTemplateCommand: View {
} }
private var canSave: Bool { private var canSave: Bool {
guard let store, let ref, !store.isEditingInline else { return false } guard let store, let ref else { return false }
switch store.readOnlyLock { return Self.allowsSave(
lock: store.readOnlyLock,
isEditingInline: store.isEditingInline,
hasUnsavedCardContent: appModel.hasUnsavedCardContent(for: ref)
)
}
/// The item's validation as a pure function of the three facts it turns on extracted from
/// `canSave` so the carve-out can be tested at every combination rather than only through a
/// menu.
///
/// The carve-out itself is 02-architecture.md Live-reload resilience, settled: under the
/// **unwritable-location lock alone** this stays live (copy-out is a read archiving the
/// read-only DMG board being inspected is a legitimate errand), and it "gates on the hazard
/// itself, open sessions, not on lock provenance" so an Edit or raw-source session holding
/// unsaved content disables it, whether the lock arrived at open or from the symmetric probe
/// mid-session, and nothing here asks which. The other two locks disable it outright.
nonisolated static func allowsSave(
lock: ReadOnlyLockReason?,
isEditingInline: Bool,
hasUnsavedCardContent: Bool
) -> Bool {
guard !isEditingInline else { return false }
switch lock {
case .none: case .none:
return true return true
case .unwritableLocation: case .unwritableLocation:
return !appModel.hasUnsavedCardContent(for: ref) return !hasUnsavedCardContent
case .vanishedRoot, .bracketedReloadFailed: case .vanishedRoot, .bracketedReloadFailed:
return false return false
} }
+12 -4
View File
@@ -165,8 +165,9 @@ public struct InProgressOperation: Identifiable, Sendable {
/// and a changing *reason* updates the row rather than replacing it no view churn, no lost /// and a changing *reason* updates the row rather than replacing it no view churn, no lost
/// animation, and no diffing surprise when a lock's cause changes underneath a standing row. /// animation, and no diffing surprise when a lock's cause changes underneath a standing row.
public enum BannerRow: Identifiable, Sendable { public enum BannerRow: Identifiable, Sendable {
/// The board refuses writes. Condition, error tone. Producers: the failed bracketed reload /// The board refuses writes. Condition, error tone. Producers: the failed bracketed reload, the
/// (built), the vanished root (this milestone), the open-time writability probe (m4). /// vanished root, and the writability probe at open and, symmetrically, on every reconciling
/// reload thereafter.
case readOnlyLock(ReadOnlyLockReason) case readOnlyLock(ReadOnlyLockReason)
/// A reload failed and the last good snapshot is still on screen. Condition, error tone. /// A reload failed and the last good snapshot is still on screen. Condition, error tone.
case reloadBreakage(BoardLoadError) case reloadBreakage(BoardLoadError)
@@ -804,14 +805,21 @@ public final class BannerCenter {
/// what is on screen is still the last good view because the lock's whole promise is that /// what is on screen is still the last good view because the lock's whole promise is that
/// nothing was lost: reading, selecting, searching and copying out all stay live (02 § "The /// nothing was lost: reading, selecting, searching and copying out all stay live (02 § "The
/// lock's scope"). /// lock's scope").
///
/// **The unwritable location gets two lines, not one shared one** (02 § Write-failure
/// surfacing, settled): "which specific cause, not a shared line the fixes being different
/// acts". Ejecting a DMG or copying the board off it is not the same repair as a `chmod` or a
/// Get Info panel, and a line that covered both would name neither.
public nonisolated static func headline(for lock: ReadOnlyLockReason) -> String { public nonisolated static func headline(for lock: ReadOnlyLockReason) -> String {
switch lock { switch lock {
case .bracketedReloadFailed: case .bracketedReloadFailed:
"This board couldn't be re-read after the last operation — showing the last good view, read-only" "This board couldn't be re-read after the last operation — showing the last good view, read-only"
case .vanishedRoot: case .vanishedRoot:
"This board's folder is gone — showing the last good view, read-only" "This board's folder is gone — showing the last good view, read-only"
case .unwritableLocation: case .unwritableLocation(.readOnlyVolume):
"This board's location can't be written to — showing the last good view, read-only" "This board's volume is read-only — showing the last good view, read-only"
case .unwritableLocation(.permissionDenied):
"You don't have permission to change this folder — showing the last good view, read-only"
} }
} }
+109 -46
View File
@@ -38,17 +38,24 @@ public enum ReadOnlyLockReason: Sendable, Equatable {
/// normally. /// normally.
case vanishedRoot case vanishedRoot
/// The board opened somewhere it cannot be written: a read-only volume (a DMG, a snapshot, a /// The board is somewhere it cannot be written: a read-only volume (a DMG, a snapshot, a
/// read-only share) or a permission-denied folder (02-architecture.md § Write-failure /// read-only share) or a permission-denied folder (02-architecture.md § Write-failure
/// surfacing, "An unwritable board location enters the read-only lock at open"). Failing /// surfacing, "An unwritable board location enters the read-only lock at open"). Failing
/// loudly, specifically, *once* beats letting every gesture fail one at a time. /// loudly, specifically, *once* beats letting every gesture fail one at a time.
/// ///
/// **Clears only on a successful *reconciling* reload whose writability re-probe passes** /// **It carries which** (settled): "the probe distinguishes read-only volume from
/// unlike its two siblings, whose cause a successful reload disproves by itself. A board on a /// permission-denied folder and the lock reason carries it the fixes being different acts".
/// read-only DMG reloads perfectly all day long; only the probe (§ "Writability re-probes on /// The payload is the *only* thing the two spellings of this lock differ in same scope, same
/// every reconciling reload" wake, activation) can tell that the permission or the mount /// clearing rule so it is an associated value rather than two cases, and `BannerCenter` turns
/// actually changed. /// it into the one line the user reads.
case unwritableLocation ///
/// **Raised and cleared by the probe, not by the reload's success** unlike its two siblings,
/// whose cause a successful reload disproves by itself. A board on a read-only DMG reloads
/// perfectly all day long; only the probe (§ "Writability re-probes on every reconciling
/// reload" wake, activation) can tell that the permission or the mount changed, in *either*
/// direction: it clears a lock whose cause is gone and raises one whose cause has appeared
/// mid-session (§ "the probe is symmetric").
case unwritableLocation(UnwritableCause)
} }
/// The refusal `BoardStore.performWrite` throws when the board is locked read-only. /// The refusal `BoardStore.performWrite` throws when the board is locked read-only.
@@ -760,18 +767,21 @@ public final class BoardStore {
reloadFailure = nil reloadFailure = nil
looseCardFiles = result.looseCardFiles looseCardFiles = result.looseCardFiles
legacyTombstones = result.legacyTombstones legacyTombstones = result.legacyTombstones
clearLockIfDisproved(by: origin) reconcileLock(after: origin)
// The registry write-through, for the same "not board structure" reason the lock // The registry write-through, for the same "not board structure" reason the lock
// clearing sits out here: whether this board's row needs a new title, icon, or // clearing sits out here: whether this board's row needs a new title, icon, or
// iconColor is the registry's question to answer (`syncDisplayState`'s own no-op // iconColor is the registry's question to answer (`syncDisplayState`'s own no-op
// guard), not a decision this store makes by comparing against its own prior // guard), not a decision this store makes by comparing against its own prior
// snapshot. // snapshot.
displayStateDelegate?() displayStateDelegate?()
// Last, and after `clearLockIfDisproved` deliberately: this is the seam the two // Last, and after `reconcileLock` deliberately: this is the seam the two deferred
// deferred app-initiated writes are armed on. A board that was locked read-only // app-initiated writes are armed on. A board that was locked read-only tolerated its
// tolerated its loose files and its legacy tombstones for exactly as long as the lock // loose files and its legacy tombstones for exactly as long as the lock stood, and the
// stood, and the reload that clears the lock is the reload that lets them move see // reload that clears the lock is the reload that lets them move see
// `relocateLooseCardFiles()` and `migrateLegacyTombstones()`. // `relocateLooseCardFiles()` and `migrateLegacyTombstones()`. The ordering cuts the
// other way too now that the probe is symmetric: a reconciling reload that *raises* the
// lock raises it before these three run, so none of them writes into a location the
// same reload just learned is read-only.
// //
// The migration goes second only because the relocation is the older rule; they touch // The migration goes second only because the relocation is the older rule; they touch
// disjoint files (loose files beside an `index.md` vs the `deleted:` key inside one) and // disjoint files (loose files beside an `index.md` vs the `deleted:` key inside one) and
@@ -890,9 +900,10 @@ public final class BoardStore {
startReload(origin) startReload(origin)
} }
// MARK: - The lock's clearing rules // MARK: - The lock's reconciliation rules
/// Clears the read-only lock if this successful reload actually disproved its cause. /// Brings the read-only lock into line with what this successful reload and, on a reconciling
/// one, a fresh writability probe actually proves.
/// ///
/// **Reason-specific, because the causes are not alike** (02-architecture.md § Write-failure /// **Reason-specific, because the causes are not alike** (02-architecture.md § Write-failure
/// surfacing): /// surfacing):
@@ -902,28 +913,54 @@ public final class BoardStore {
/// "the root is gone" a completed tree walk at the root contradicts both, whatever origin /// "the root is gone" a completed tree walk at the root contradicts both, whatever origin
/// asked for it, so any success clears them. /// asked for it, so any success clears them.
/// - `.unwritableLocation` is not. A board on a read-only DMG reloads flawlessly forever; /// - `.unwritableLocation` is not. A board on a read-only DMG reloads flawlessly forever;
/// loading proves nothing about writing. It clears only when a **reconciling** reload wake, /// loading proves nothing about writing. Only the probe can speak to it, and the probe runs on
/// activation, a stream re-creation re-probes writability and finds it changed ("Writability /// **reconciling** reloads wake, activation, a stream re-creation because those are the
/// re-probes on every reconciling reload, so a fixed permission or rewritable remount clears /// reloads that admit a blind window ("Writability re-probes on every reconciling reload").
/// the lock without ceremony").
/// ///
/// `FileManager.isWritableFile(atPath:)` is `access(2)` on the root directory: a real-uid /// ### The probe is symmetric (settled)
/// permission question asked of the filesystem, which is what makes it answer correctly for
/// both halves of the case a read-only *mount* and a permission-denied *folder*.
/// ///
/// Deliberately **one-way**: a reconciling reload that finds the root unwritable does not /// It clears *and* raises. "A rewritable remount or fixed permission clears the lock without
/// *raise* the lock. Arming it is the open flow's job (`enterUnwritableLock()`), and inferring /// ceremony, and a volume gone read-only mid-session *raises* it at the next probe banner up
/// a lock from a probe here would be a policy decision this milestone was not asked to make. /// front, not every gesture failing one at a time (the lock's own founding rationale)." Between
private func clearLockIfDisproved(by origin: WatchOrigin) { /// probes a write that hits the newly read-only volume fails as an ordinary one-shot; this is
/// the line that converts that condition into the standing lock.
///
/// A raise here is deliberately **not** routed through `enterUnwritableLock(_:)`: that method
/// speaks its own sentence, and this runs inside `land`, which posts exactly one announcement
/// per reload from the before/after pictures it already holds. Two voices for one lock is the
/// bug `announceLockChange` exists to avoid.
///
/// The sibling locks are settled *before* the probe, so a reconciling reload that clears a
/// vanished root on a volume that came back read-only ends with the honest lock rather than no
/// lock at all. And a standing `.unwritableLocation` whose cause *changed* a permission-denied
/// folder whose volume was then remounted read-only re-lands with the new cause, updating the
/// row's line rather than replacing the row (`BannerRow.id` is constant per condition).
private func reconcileLock(after origin: WatchOrigin) {
switch readOnlyLock { switch readOnlyLock {
case nil:
break
case .bracketedReloadFailed, .vanishedRoot: case .bracketedReloadFailed, .vanishedRoot:
readOnlyLock = nil readOnlyLock = nil
case .unwritableLocation: case .unwritableLocation, nil:
guard origin == .reconciling, FileManager.default.isWritableFile(atPath: rootURL.path) else { return } break
}
guard origin == .reconciling else { return }
switch (readOnlyLock, WritabilityProbe.probe(rootURL)) {
case (.unwritableLocation, nil):
Self.logger.debug("writability re-probe passed — the unwritable-location lock clears") Self.logger.debug("writability re-probe passed — the unwritable-location lock clears")
readOnlyLock = nil readOnlyLock = nil
case let (.unwritableLocation, .some(cause)):
// Still unwritable. The assignment is not a no-op only when the *cause* moved.
readOnlyLock = .unwritableLocation(cause)
case let (nil, .some(cause)):
Self.logger.error("writability re-probe failed (\(cause.rawValue, privacy: .public)) — the read-only lock rises")
readOnlyLock = .unwritableLocation(cause)
case (nil, nil):
break
// Cleared above, so unreachable spelled so a new lock reason is a compile error here
// rather than a silent fall-through past the probe.
case (.bracketedReloadFailed, _), (.vanishedRoot, _):
break
} }
} }
@@ -960,18 +997,37 @@ public final class BoardStore {
announceLockChange(from: before) announceLockChange(from: before)
} }
/// Raises the unwritable-location read-only lock the open flow's call, after probing the /// **The open-time writability probe** (02 § "An unwritable board location enters the read-only
/// root's writability (02 § "An unwritable board location enters the read-only lock at open"). /// lock at open") `BoardStoreRegistry.acquire`'s call, and the only place the lock is raised
/// Public now so the vocabulary and its clearing rule ship together; m4's open flow is the /// outside a reconciling reload.
/// producer. ///
/// A no-op on a writable board, which is the overwhelming case, and one `access(2)` plus one
/// volume resource value when it is not cheap enough to sit unconditionally on the open path.
///
/// **The open still succeeds.** Nothing here refuses the board or throws: the lock's read
/// affordances stay live as always, because "inspecting an archived board on a DMG is a
/// legitimate errand, and viewing-first is the point". All that changes is that every mutating
/// entry point now consults a predicate that is already `true` before the window can be acted
/// on the lock is up *before* the user's first gesture, which is the whole of "fail loudly,
/// specifically, once".
public func probeWritabilityAtOpen() {
guard let cause = WritabilityProbe.probe(rootURL) else { return }
enterUnwritableLock(cause)
}
/// Raises the unwritable-location read-only lock with the cause the probe found.
///
/// Public because the probe is not the only conceivable producer and because tests arm it
/// directly; `probeWritabilityAtOpen()` is the app's own path to it.
/// ///
/// Does **not** overwrite a standing lock: a board that is already locked for a vanished root /// Does **not** overwrite a standing lock: a board that is already locked for a vanished root
/// or a failed bracketed reload has a cause that outranks "and it is also read-only", and both /// or a failed bracketed reload has a cause that outranks "and it is also read-only", and both
/// of those clear on a success that would then re-probe anyway. /// of those clear on a success that would then re-probe anyway (`reconcileLock(after:)` settles
public func enterUnwritableLock() { /// the siblings first, then probes, precisely so that reload lands on the right answer).
public func enterUnwritableLock(_ cause: UnwritableCause) {
guard readOnlyLock == nil else { return } guard readOnlyLock == nil else { return }
Self.logger.error("board location is not writable — entering the read-only lock") Self.logger.error("board location is not writable (\(cause.rawValue, privacy: .public)) — entering the read-only lock")
readOnlyLock = .unwritableLocation readOnlyLock = .unwritableLocation(cause)
announceLockChange(from: nil) announceLockChange(from: nil)
} }
@@ -2756,8 +2812,10 @@ public final class BoardStore {
/// locked board returns here having written nothing **and having remembered nothing**, so the /// locked board returns here having written nothing **and having remembered nothing**, so the
/// next attempt is a fresh one. The arming seam is `land(_:generation:origin:)`: every lock /// next attempt is a fresh one. The arming seam is `land(_:generation:origin:)`: every lock
/// clears on a successful reload and nowhere else, and this runs at the end of every successful /// clears on a successful reload and nowhere else, and this runs at the end of every successful
/// reload, after `clearLockIfDisproved` so the reload that lifts the lock is the reload that /// reload, after `reconcileLock(after:)` so the reload that lifts the lock is the reload that
/// performs the relocation, with no timer, no queue, and no second state to keep in step. /// performs the relocation, with no timer, no queue, and no second state to keep in step. The
/// same ordering covers the other direction, now that the probe is symmetric: a reconciling
/// reload that *raises* the unwritable-location lock raises it before this runs.
/// ///
/// ### It cannot hot-loop /// ### It cannot hot-loop
/// ///
@@ -3581,12 +3639,17 @@ public final class BoardStore {
/// ///
/// Two gates, because two different things can be true. `performWrite` would refuse under the /// Two gates, because two different things can be true. `performWrite` would refuse under the
/// read-only lock on its own, but that refusal is a thrown error and this is not a gesture so /// read-only lock on its own, but that refusal is a thrown error and this is not a gesture so
/// the lock is checked first, the relocation's own deferral idiom. The writability probe beside /// the lock is checked first, the relocation's own deferral idiom. That gate is now the one that
/// it covers the case the lock does not: 02-architecture.md's open-time unwritable-root lock is a /// actually fires on an unwritable board: `BoardStoreRegistry.acquire` probes writability
/// separate card, and until it lands a board on a read-only volume would reach the Writer, fail, /// *before* it calls this method, so 02-architecture.md's "the open-time agent-guide write is
/// and post a banner about a file the user never asked for. 02 settles that exact case the other /// skipped-with-log, the `CLAUDE.user.md`-taken precedent" is honored by the lock being up
/// way "the open-time agent-guide write is skipped-with-log, the `CLAUDE.user.md`-taken /// rather than by this method noticing on its own.
/// precedent" so it is skipped with a log. ///
/// The `access(2)` check below stays anyway, as the second line of defense: it is the only gate
/// covering the window *between* probes a volume remounted read-only mid-session raises the
/// lock at the next reconciling reload, and a reload landing in that window would otherwise
/// reach the Writer, fail, and post a banner about a file the user never asked for. Both skips
/// are logged and neither is ever surfaced.
/// ///
/// ### It cannot hot-loop /// ### It cannot hot-loop
/// ///
+16
View File
@@ -146,6 +146,22 @@ public final class BoardStoreRegistry {
let store = try BoardStore(rootURL: rootURL) let store = try BoardStore(rootURL: rootURL)
// **The open-time writability probe** (02-architecture.md § Write-failure surfacing, "An
// unwritable board location enters the read-only lock at open"), and this is the seam for
// it: every way a board opens welcome's recents, a Finder open, File Open, restoration,
// a card window arriving first funnels through `acquire`, so the probe is wired once here
// instead of at each caller, and no future open path can forget it.
//
// **First, immediately after the load.** The lock has to be standing before anything else
// in this method can act on the board, and two things below would otherwise write into a
// location this line already knows is read-only: the loose-file relocation and the agent
// guide. Ordering them after the probe is what makes 02's "the open-time agent-guide write
// is skipped-with-log" true by construction rather than by that method's own second gate.
//
// The board still opens. This is a lock, not a refusal: the window comes up, the snapshot
// renders, and reading, selecting, searching and copying out all stay live.
store.probeWritabilityAtOpen()
// Unreachable in practice the load above just walked this directory but the alternative // Unreachable in practice the load above just walked this directory but the alternative
// is a force-unwrap on a resource value the filesystem is free to refuse, so it is spelled // is a force-unwrap on a resource value the filesystem is free to refuse, so it is spelled
// out. The loader's own vocabulary says it; no new error path is invented for a case that // out. The loader's own vocabulary says it; no new error path is invented for a case that
+82
View File
@@ -0,0 +1,82 @@
import Foundation
// MARK: - UnwritableCause
/// **Why** a board's location refuses writes the half of `ReadOnlyLockReason.unwritableLocation`
/// the user is actually told about (02-architecture.md § Write-failure surfacing, settled: "the
/// probe distinguishes read-only volume from permission-denied folder and the lock reason carries
/// it the fixes being different acts").
///
/// Two cases and no `.unknown`: the probe below is total it asks two questions of the filesystem
/// and every answer maps onto one of these or onto "writable". A third case would be a shrug in the
/// one place the design forbids one, since the whole point of naming the cause is that ejecting a
/// DMG and running `chmod` are not the same repair.
public enum UnwritableCause: String, Sendable, Equatable, CaseIterable {
/// The volume the board sits on is mounted read-only a DMG, an APFS snapshot, a read-only
/// share. Nothing on it can be written, by anybody, until it is remounted.
case readOnlyVolume
/// The volume is writable; this *folder* is not POSIX permissions, an ACL, an owner that is
/// not us. A `chmod`, a Get Info panel, or a move somewhere else fixes it.
case permissionDenied
}
// MARK: - WritabilityProbe
/// The board root's writability, asked of the filesystem and classified the open-time probe of
/// 02-architecture.md § Write-failure surfacing ("An unwritable board location enters the read-only
/// lock at open"), re-run on every reconciling reload because that rule is **symmetric**: a fixed
/// permission or a rewritable remount clears the lock, and a volume gone read-only mid-session
/// raises it.
///
/// ### Two questions, and the order they are asked in matters
///
/// `FileManager.isWritableFile(atPath:)` is `access(2)` with `W_OK`: a real-uid permission question
/// answered by the filesystem itself, which is why it is trustworthy where a stat of the mode bits
/// would not be (ACLs, sandbox denials, and read-only mounts all show up in it). But it answers
/// `false` for **both** halves of this case a read-only volume fails `access(2)` exactly like a
/// `r-x` folder does so on its own it can only say "no", never "why".
///
/// `URLResourceKey.volumeIsReadOnlyKey` is what separates them, and it is therefore asked **first**.
/// The precedence is not a tie-break: a board on a mounted DMG *is* on a read-only volume and it is
/// also permission-denied by `access(2)`, and telling the user "you don't have permission to change
/// this folder" would send them to a Get Info panel that cannot help. The volume's answer is the
/// more fundamental fact and it names the repair that works, so it wins whenever it is `true`.
///
/// A volume that will not answer at all (`nil` an exotic filesystem, a URL whose volume is gone)
/// falls through to `access(2)`, which is the honest degradation: we still know whether the board
/// can be written, we just describe it as the folder's doing.
///
/// ### Why the classifier is separate from the I/O
///
/// `classify(volumeIsReadOnly:isWritable:)` is a pure function of the two answers, so the truth
/// table including the read-only-volume-that-somehow-passes-`access` row, which `root` can
/// produce is testable without a DMG. `probe(_:)` is only the two syscalls and this call.
public enum WritabilityProbe {
/// The cause, or `nil` when the location accepts writes.
///
/// Deliberately optional-returning rather than `Bool`-returning: every caller either raises a
/// lock carrying the cause or clears one, and a `Bool` would force a second question at each
/// site to find out which line to show.
public nonisolated static func classify(volumeIsReadOnly: Bool?, isWritable: Bool) -> UnwritableCause? {
if volumeIsReadOnly == true { return .readOnlyVolume }
return isWritable ? nil : .permissionDenied
}
/// Asks the filesystem about `root` and classifies the answer.
///
/// **The URL is rebuilt from its path** before the resource value is read. `URL` bridges to
/// `NSURL`, which caches resource values it has already been asked for and this probe's whole
/// job is to notice that an answer *changed* since last time. A cached `volumeIsReadOnly` would
/// make the symmetric rule silently one-way again, in a way no test on a fresh URL would catch.
/// `access(2)` has no such cache, which is why only the resource value needs the ceremony.
public nonisolated static func probe(_ root: URL) -> UnwritableCause? {
let fresh = URL(fileURLWithPath: root.path, isDirectory: true)
let volumeIsReadOnly = (try? fresh.resourceValues(forKeys: [.volumeIsReadOnlyKey]))?.volumeIsReadOnly
return classify(
volumeIsReadOnly: volumeIsReadOnly,
isWritable: FileManager.default.isWritableFile(atPath: root.path)
)
}
}
+1 -1
View File
@@ -429,7 +429,7 @@ struct AgentGuideStoreTests {
let fixture = try makeBoard() let fixture = try makeBoard()
defer { fixture.tearDown() } defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root) let store = try BoardStore(rootURL: fixture.root)
store.enterUnwritableLock() store.enterUnwritableLock(.permissionDenied)
store.refreshAgentGuide() store.refreshAgentGuide()
#expect(!fixture.exists(AgentGuide.filename)) #expect(!fixture.exists(AgentGuide.filename))
+19 -2
View File
@@ -614,7 +614,8 @@ struct BannerCenterPhrasingTests {
@Test("Every lock reason says what is wrong and that the view is still the last good one") @Test("Every lock reason says what is wrong and that the view is still the last good one")
func lockHeadlinesReassure() { func lockHeadlinesReassure() {
let reasons: [ReadOnlyLockReason] = [.bracketedReloadFailed, .vanishedRoot, .unwritableLocation] let reasons: [ReadOnlyLockReason] = [.bracketedReloadFailed, .vanishedRoot]
+ UnwritableCause.allCases.map(ReadOnlyLockReason.unwritableLocation)
let headlines = reasons.map(BannerCenter.headline(for:)) let headlines = reasons.map(BannerCenter.headline(for:))
for headline in headlines { for headline in headlines {
@@ -624,6 +625,22 @@ struct BannerCenterPhrasingTests {
#expect(Set(headlines).count == reasons.count) #expect(Set(headlines).count == reasons.count)
} }
/// "Which specific cause, not a shared line" (02 § Write-failure surfacing, settled): the two
/// halves of the unwritable location name **different repairs**, so they get different lines.
@Test("The unwritable location names which cause it is")
func unwritableCausesGetTheirOwnLines() {
let volume = BannerCenter.headline(for: .unwritableLocation(.readOnlyVolume))
let folder = BannerCenter.headline(for: .unwritableLocation(.permissionDenied))
#expect(volume == "This board's volume is read-only — showing the last good view, read-only")
#expect(folder == "You don't have permission to change this folder — showing the last good view, read-only")
// The distinction the design spends the extra line on: one says volume, the other says this
// folder, and neither says the other's word.
#expect(volume.contains("volume") && !volume.contains("permission"))
#expect(folder.contains("permission") && !folder.contains("volume"))
}
@Test("Reload breakage carries fail-fast's specifics — the path and what is wrong with it") @Test("Reload breakage carries fail-fast's specifics — the path and what is wrong with it")
func breakageHeadlineNamesThePath() { func breakageHeadlineNamesThePath() {
let headline = BannerCenter.headline( let headline = BannerCenter.headline(
@@ -724,7 +741,7 @@ struct BannerCenterStoreTests {
defer { fixture.tearDown() } defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root) let store = try BoardStore(rootURL: fixture.root)
store.enterUnwritableLock() store.enterUnwritableLock(.permissionDenied)
store.banners.post(BoardWriteError(operation: .createCard, path: "/x", reason: .io(message: "the disk is full"))) store.banners.post(BoardWriteError(operation: .createCard, path: "/x", reason: .io(message: "the disk is full")))
store.banners.postLoss("Pasted 'Fix login' without its 3 attachments") store.banners.postLoss("Pasted 'Fix login' without its 3 attachments")
store.banners.suspendHistory(reason: "the disk is full") store.banners.suspendHistory(reason: "the disk is full")
+1 -1
View File
@@ -421,7 +421,7 @@ struct BoardAnnouncerSpeechTests {
@Test("A lock whose cause changed is news again") @Test("A lock whose cause changed is news again")
func changedLockCauseSpeaksAgain() { func changedLockCauseSpeaksAgain() {
var facts = BoardAnnouncer.ReloadFacts() var facts = BoardAnnouncer.ReloadFacts()
facts.lockBefore = .unwritableLocation facts.lockBefore = .unwritableLocation(.permissionDenied)
facts.lockAfter = .vanishedRoot facts.lockAfter = .vanishedRoot
#expect( #expect(
+2 -2
View File
@@ -199,10 +199,10 @@ struct CardBodyDirtyBufferTests {
func aSuspendedSaveKeepsTheBuffer() throws { func aSuspendedSaveKeepsTheBuffer() throws {
let spy = SaveSpy() let spy = SaveSpy()
let session = makeSession(spy) let session = makeSession(spy)
spy.outcome = .suspended(.unwritableLocation) spy.outcome = .suspended(.unwritableLocation(.permissionDenied))
session.edited("held\n") session.edited("held\n")
#expect(session.flush() == .suspended(.unwritableLocation)) #expect(session.flush() == .suspended(.unwritableLocation(.permissionDenied)))
#expect(session.isDirty) #expect(session.isDirty)
// The close-time guard treats it as a non-failure: no write was attempted, the lock row has // The close-time guard treats it as a non-failure: no write was attempted, the lock row has
// been standing all along, and a modal offering Try Again could only fail again. // been standing all along, and a modal offering Try Again could only fail again.
+1 -1
View File
@@ -439,7 +439,7 @@ struct LooseFileStoreTests {
let store = try BoardStore(rootURL: fixture.root) let store = try BoardStore(rootURL: fixture.root)
let brackets = RelocationBracketLog() let brackets = RelocationBracketLog()
brackets.attach(to: store) brackets.attach(to: store)
store.enterUnwritableLock() store.enterUnwritableLock(.permissionDenied)
store.relocateLooseCardFiles() store.relocateLooseCardFiles()
store.handleWatcherEvent(.treeChanged(.foreign)) store.handleWatcherEvent(.treeChanged(.foreign))
+230 -11
View File
@@ -221,7 +221,7 @@ struct RootRecoveryTests {
#expect(store.banners.oneShots.isEmpty) #expect(store.banners.oneShots.isEmpty)
} }
// MARK: The writability clearing rule // MARK: The writability probe
@Test("The unwritable-location lock clears only on a reconciling reload whose probe passes") @Test("The unwritable-location lock clears only on a reconciling reload whose probe passes")
func unwritableLockClearsOnlyOnAReconcilingProbe() async throws { func unwritableLockClearsOnlyOnAReconcilingProbe() async throws {
@@ -232,20 +232,20 @@ struct RootRecoveryTests {
// The probe has to be honest, so the root is made genuinely unwritable `r-x`, which still // 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. // reads perfectly. That is the whole difficulty of this case: the board loads fine.
try FileManager.default.setAttributes([.posixPermissions: 0o555], ofItemAtPath: fixture.root.path) try FileManager.default.setAttributes([.posixPermissions: 0o555], ofItemAtPath: fixture.root.path)
store.enterUnwritableLock() store.enterUnwritableLock(.permissionDenied)
#expect(store.readOnlyLock == .unwritableLocation) #expect(store.readOnlyLock == .unwritableLocation(.permissionDenied))
// A foreign reload succeeds and clears nothing. Loading proves nothing about writing, // 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. // which is exactly why this lock's clearing rule is not the other two's.
store.handleWatcherEvent(.treeChanged(.foreign)) store.handleWatcherEvent(.treeChanged(.foreign))
await store.awaitQuiescence() await store.awaitQuiescence()
#expect(store.reloadFailure == nil, "an unwritable root still reads") #expect(store.reloadFailure == nil, "an unwritable root still reads")
#expect(store.readOnlyLock == .unwritableLocation) #expect(store.readOnlyLock == .unwritableLocation(.permissionDenied))
// Neither does a reconciling one while the permission is still what it was. // Neither does a reconciling one while the permission is still what it was.
store.handleWatcherEvent(.treeChanged(.reconciling)) store.handleWatcherEvent(.treeChanged(.reconciling))
await store.awaitQuiescence() await store.awaitQuiescence()
#expect(store.readOnlyLock == .unwritableLocation) #expect(store.readOnlyLock == .unwritableLocation(.permissionDenied))
// The permission is fixed. Nothing announces that a `chmod` in a terminal fires no event // 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 // the board would act on so the lock stands until the next reconciling sweep (wake, app
@@ -253,7 +253,7 @@ struct RootRecoveryTests {
try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: fixture.root.path) try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: fixture.root.path)
store.handleWatcherEvent(.treeChanged(.foreign)) store.handleWatcherEvent(.treeChanged(.foreign))
await store.awaitQuiescence() await store.awaitQuiescence()
#expect(store.readOnlyLock == .unwritableLocation, "only a reconciling reload re-probes") #expect(store.readOnlyLock == .unwritableLocation(.permissionDenied), "only a reconciling reload re-probes")
store.handleWatcherEvent(.treeChanged(.reconciling)) store.handleWatcherEvent(.treeChanged(.reconciling))
await store.awaitQuiescence() await store.awaitQuiescence()
@@ -261,20 +261,239 @@ struct RootRecoveryTests {
#expect(store.bannerRows.isEmpty) #expect(store.bannerRows.isEmpty)
} }
@Test("A reconciling reload that finds the root unwritable does not raise the lock by itself") /// "The probe is symmetric (settled): a volume gone read-only mid-session *raises* it at the
func theProbeOnlyClears() async throws { /// 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() let fixture = try makeBoard()
defer { fixture.tearDown() } defer { fixture.tearDown() }
let store = try BoardStore(rootURL: fixture.root) 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) 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)) store.handleWatcherEvent(.treeChanged(.reconciling))
await store.awaitQuiescence() await store.awaitQuiescence()
// Arming the lock is the open flow's job (m4). Inferring it from a probe here would be a #expect(spoken == ["Error: You don't have permission to change this folder — showing the last good view, read-only"])
// policy decision this layer has not been asked to make recorded as a test so the
// asymmetry is deliberate rather than forgotten. // 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.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)
} }
} }