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
+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
///