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:
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user