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 {
guard let store, let ref, !store.isEditingInline else { return false }
switch store.readOnlyLock {
guard let store, let ref else { return false }
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:
return true
case .unwritableLocation:
return !appModel.hasUnsavedCardContent(for: ref)
return !hasUnsavedCardContent
case .vanishedRoot, .bracketedReloadFailed:
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
/// animation, and no diffing surprise when a lock's cause changes underneath a standing row.
public enum BannerRow: Identifiable, Sendable {
/// The board refuses writes. Condition, error tone. Producers: the failed bracketed reload
/// (built), the vanished root (this milestone), the open-time writability probe (m4).
/// The board refuses writes. Condition, error tone. Producers: the failed bracketed reload, the
/// vanished root, and the writability probe at open and, symmetrically, on every reconciling
/// reload thereafter.
case readOnlyLock(ReadOnlyLockReason)
/// A reload failed and the last good snapshot is still on screen. Condition, error tone.
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
/// nothing was lost: reading, selecting, searching and copying out all stay live (02 § "The
/// 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 {
switch lock {
case .bracketedReloadFailed:
"This board couldn't be re-read after the last operation — showing the last good view, read-only"
case .vanishedRoot:
"This board's folder is gone — showing the last good view, read-only"
case .unwritableLocation:
"This board's location can't be written to — showing the last good view, read-only"
case .unwritableLocation(.readOnlyVolume):
"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.
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
/// 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.
///
/// **Clears only on a successful *reconciling* reload whose writability re-probe passes**
/// 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
/// actually changed.
case unwritableLocation
/// **It carries which** (settled): "the probe distinguishes read-only volume from
/// permission-denied folder and the lock reason carries it the fixes being different acts".
/// The payload is the *only* thing the two spellings of this lock differ in same scope, same
/// clearing rule so it is an associated value rather than two cases, and `BannerCenter` turns
/// it into the one line the user reads.
///
/// **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.
@@ -760,18 +767,21 @@ public final class BoardStore {
reloadFailure = nil
looseCardFiles = result.looseCardFiles
legacyTombstones = result.legacyTombstones
clearLockIfDisproved(by: origin)
reconcileLock(after: origin)
// 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
// 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
// snapshot.
displayStateDelegate?()
// Last, and after `clearLockIfDisproved` deliberately: this is the seam the two
// deferred app-initiated writes are armed on. A board that was locked read-only
// tolerated its loose files and its legacy tombstones for exactly as long as the lock
// stood, and the reload that clears the lock is the reload that lets them move see
// `relocateLooseCardFiles()` and `migrateLegacyTombstones()`.
// Last, and after `reconcileLock` deliberately: this is the seam the two deferred
// app-initiated writes are armed on. A board that was locked read-only tolerated its
// loose files and its legacy tombstones for exactly as long as the lock stood, and the
// reload that clears the lock is the reload that lets them move see
// `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
// 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)
}
// 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
/// surfacing):
@@ -902,28 +913,54 @@ public final class BoardStore {
/// "the root is gone" a completed tree walk at the root contradicts both, whatever origin
/// asked for it, so any success clears them.
/// - `.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,
/// activation, a stream re-creation re-probes writability and finds it changed ("Writability
/// re-probes on every reconciling reload, so a fixed permission or rewritable remount clears
/// the lock without ceremony").
/// loading proves nothing about writing. Only the probe can speak to it, and the probe runs on
/// **reconciling** reloads wake, activation, a stream re-creation because those are the
/// reloads that admit a blind window ("Writability re-probes on every reconciling reload").
///
/// `FileManager.isWritableFile(atPath:)` is `access(2)` on the root directory: a real-uid
/// 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*.
/// ### The probe is symmetric (settled)
///
/// Deliberately **one-way**: a reconciling reload that finds the root unwritable does not
/// *raise* the lock. Arming it is the open flow's job (`enterUnwritableLock()`), and inferring
/// a lock from a probe here would be a policy decision this milestone was not asked to make.
private func clearLockIfDisproved(by origin: WatchOrigin) {
/// It clears *and* raises. "A rewritable remount or fixed permission clears the lock without
/// ceremony, and a volume gone read-only mid-session *raises* it at the next probe banner up
/// front, not every gesture failing one at a time (the lock's own founding rationale)." Between
/// 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 {
case nil:
break
case .bracketedReloadFailed, .vanishedRoot:
readOnlyLock = nil
case .unwritableLocation:
guard origin == .reconciling, FileManager.default.isWritableFile(atPath: rootURL.path) else { return }
case .unwritableLocation, nil:
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")
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)
}
/// Raises the unwritable-location read-only lock the open flow's call, after probing the
/// root's writability (02 § "An unwritable board location enters the read-only lock at open").
/// Public now so the vocabulary and its clearing rule ship together; m4's open flow is the
/// producer.
/// **The open-time writability probe** (02 § "An unwritable board location enters the read-only
/// lock at open") `BoardStoreRegistry.acquire`'s call, and the only place the lock is raised
/// outside a reconciling reload.
///
/// 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
/// 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.
public func enterUnwritableLock() {
/// of those clear on a success that would then re-probe anyway (`reconcileLock(after:)` settles
/// the siblings first, then probes, precisely so that reload lands on the right answer).
public func enterUnwritableLock(_ cause: UnwritableCause) {
guard readOnlyLock == nil else { return }
Self.logger.error("board location is not writable — entering the read-only lock")
readOnlyLock = .unwritableLocation
Self.logger.error("board location is not writable (\(cause.rawValue, privacy: .public)) — entering the read-only lock")
readOnlyLock = .unwritableLocation(cause)
announceLockChange(from: nil)
}
@@ -2756,8 +2812,10 @@ public final class BoardStore {
/// 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
/// 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
/// performs the relocation, with no timer, no queue, and no second state to keep in step.
/// 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. 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
///
@@ -3581,12 +3639,17 @@ public final class BoardStore {
///
/// 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
/// the lock is checked first, the relocation's own deferral idiom. The writability probe beside
/// it covers the case the lock does not: 02-architecture.md's open-time unwritable-root lock is a
/// separate card, and until it lands a board on a read-only volume would reach the Writer, fail,
/// and post a banner about a file the user never asked for. 02 settles that exact case the other
/// way "the open-time agent-guide write is skipped-with-log, the `CLAUDE.user.md`-taken
/// precedent" so it is skipped with a log.
/// the lock is checked first, the relocation's own deferral idiom. That gate is now the one that
/// actually fires on an unwritable board: `BoardStoreRegistry.acquire` probes writability
/// *before* it calls this method, so 02-architecture.md's "the open-time agent-guide write is
/// skipped-with-log, the `CLAUDE.user.md`-taken precedent" is honored by the lock being up
/// rather than by this method noticing on its own.
///
/// 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
///
+16
View File
@@ -146,6 +146,22 @@ public final class BoardStoreRegistry {
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
// 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
+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)
)
}
}