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
+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")
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:))
for headline in headlines {
@@ -624,6 +625,22 @@ struct BannerCenterPhrasingTests {
#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")
func breakageHeadlineNamesThePath() {
let headline = BannerCenter.headline(
@@ -724,7 +741,7 @@ struct BannerCenterStoreTests {
defer { fixture.tearDown() }
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.postLoss("Pasted 'Fix login' without its 3 attachments")
store.banners.suspendHistory(reason: "the disk is full")