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