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
}