asterism branch worktree-t-1919-t-1969-onboarding commits 2 files 30 touched lines +423 / -1323 core tests 760 passed app + UI 184 passed / UI green

Pre-push review: T-1969 / T-1919 mark-at-birth

A fresh Asterism library is now certified the moment it is created, closing a window in which anything writing to the store — CloudKit mirroring, once enabled — left the library permanently unopenable. The first-run “import or start empty” screen that created the window is gone.

Reviewed after push: branch already open as draft PR #7. Four parallel reviewers (reuse, quality, efficiency, spec/docs/tests); findings fixed in a second commit.

At a glance

  • Fixed a permanently-unopenable library. The readiness marker waited for a first-run confirmation; anything filling the store in that gap made every later launch throw, with no in-app recovery.
  • Q25 is narrowed, not overturned. The nonempty-unmarked throw is untouched and still fails closed. Only the empty case — where counts == .zero covers every entity kind in the schema — is marked.
  • Review caught a real defect: a V3 marker whose store had vanished silently created an empty replacement and deleted the last evidence the old library existed. Now fails closed like its two neighbouring guards.
  • Review caught a lost check: the .m4 capability gate on readiness publication disappeared with confirmStartEmpty. Restored, scoped to the publication only.
  • One guard is deliberately given up: the share extension can now capture into a fresh library before a backup restore. Reasoned, recorded in Decision 9, and pinned by an integration test.
  • Net −900 lines. A whole onboarding surface, three Core types and 21 tests removed; 6 behaviour-shaped tests added.

Verdict

Ready to push

The core change is sound and the decision it reverses (Q25) survives it — the “provably empty” argument was verified down to the schema model lists, not taken from the code comment. Review raised one genuine correctness defect and one lost safety check, both fixed and pinned with tests.

Already pushed, so this is a review of the branch rather than a gate before it. Two items are deliberately deferred rather than fixed: collapsing the now-single-case V4OpeningResult, and three test-coverage gaps that need infrastructure this branch should not grow. Both are listed under Worth a second look.

Review findings

12 raised · 10 fixed · 2 skipped

Jump to findings →

Commits

Three-level explanation

What changed

When you installed Asterism for the first time, it used to show you a setup screen asking: “import a backup, or start with an empty library?” That screen is gone. A new install now drops you straight into an empty library.

You can still import a backup — that lives in Settings, where it always did, and nothing about it changed.

Why it matters

The app keeps a small file next to your library called a readiness marker. It is the app’s way of saying “this library is finished and safe to open.” Without it, the app refuses to open the library — deliberately, because a library that was half-built is worse than no library.

The bug: the app created your library first, and only wrote that marker after you answered the setup question. In between, the library existed but was not marked. If anything wrote to it during that gap, the app could no longer tell “half-built” apart from “fine, just unmarked” — so it refused to open it. Forever. Deleting and reinstalling the app was the only way out, and that loses everything.

Nothing writes into that gap today. But the next feature planned is syncing your library across devices via iCloud, and sync fills a library on its own, without asking. That would have made this a routine way to lose a library.

Key concepts

  • Readiness marker — a tiny file meaning “this library is complete.” Now written the instant the library is created, because a library that was just created has nothing that could be half-finished.
  • Failing closed — when the app is unsure whether your data is intact, it refuses to continue rather than guessing. That behaviour is kept for the case it was designed for, and this change actually adds one more: if the app finds a marker for a library whose file has vanished, it now says so instead of quietly starting you over with an empty one.
  • One thing to know: sharing to Asterism now works from the very first launch. Previously it refused until you finished setup. If you share a page and then restore a backup, the restore will replace it — but it still shows you what it is about to discard and still asks twice.

Architecture

LibraryRepository.openV4ForApp is the app’s startup bootstrap. It takes one exclusive cross-process lease, classifies the on-disk state against a decision table (V4 marker? migration sidecar? V3 marker? store present?), migrates in place when needed, and returns an opened repository.

Previously it had a third outcome, .setupRequired, which returned nil for the repository and handed control to FirstRunLibrarySetupModel. The reader chose, confirmStartEmpty re-acquired the lease and published the marker, and AppLibraryModel re-bootstrapped. Three container opens and three lease acquisitions on first launch.

Now the two empty-store branches converge on one path: ensure the store exists, measure v3Counts, and publish the marker iff the count is zero. openV4ForApp returns a ready library or throws. One open, one lease.

Patterns

  • Certify at creation, not at confirmation. The window existed only because a durable on-disk fact (the marker) was gated behind a UI event. Nothing was being verified during the wait, so the wait was pure risk.
  • Measure, don’t assume. The code could have marked unconditionally on the “we just created it” branch. It counts instead — storeExists only tests the .sqlite, so “just created” really means “the main file was absent a moment ago.” If the store ever comes up nonempty, it falls through to the existing fail-closed throw rather than certifying content of unknown provenance.
  • Ordering for crash-safety. The store file is created before the marker is written, so a crash between them leaves an empty unmarked store — which the very same branch marks on the next launch. The state heals itself in both directions.
  • Symmetry between guards. Review found the new code broke the file’s own convention: v4Marker && !storeExists and sidecar && !storeExists both fail closed, but v3Marker && !storeExists fell into create-and-certify. Since V3 and V4 share the store path, that state is a certified library whose file is gone. Now throws.

Trade-offs

The extension’s fail-closed window shrinks to “before the app has ever launched.” It could previously never capture into a library awaiting a restore; now it can, and a replace-style import would discard that capture. Accepted because the guard was incidental to a screen being deleted, the alternative is a bricked library, and the replace path still shows current record counts behind a two-step destructive confirmation.

API churn: V4OpeningResult drops to one case, repository becomes non-optional, and SetupOrReadyEmptyState / BackupImportCommitMode / confirmStartEmpty are deleted. That is compiler-verified and rippled through ~14 test files mechanically.

Why Q25 survives

specs/unified-teaching-composition/ Q25 deliberately removed M3’s “valid nonempty unmarked ⇒ ready” heuristic so a store whose migration died halfway could never be silently certified. Marking an empty unmarked store looks superficially like reintroducing it. It is not, and the distinction is load-bearing:

  • Q25’s subject is the nonempty unmarked store, where “did a migration die halfway?” is genuinely unanswerable from the store alone. LibraryRepository+V4Bootstrap.swift still throws there, pinned by nonemptyUnmarkedStillFailsClosed.
  • For the empty case the question has an answer. v3Counts counts Entry, Work, Site, TitlePattern, URLRulePattern — verified to be exactly AsterismSchemaV4.models and exactly AsterismSchemaV3.models. There is no entity kind a counts == .zero store could be concealing.
  • Ordering preserves the sidecar contract: a present sidecar is read and a corrupt one throws before either empty branch is reachable, so “empty store + torn sidecar” still fails loudly with evidence intact.

This reasoning existed only in code comments. Review flagged that as the real gap — a future reader hitting publishV4Readiness on an unmarked store would read Q25 and conclude it was violated. It is now Decision 9 in the spec that owns Q25 and the bootstrap state table, with the table’s two contradicted rows amended in place.

Edge cases

  • Orphan WAL. storeExists tests only the .sqlite, so a partial restore that removes it while leaving -wal/-shm routes into create-and-mark. Empirically tested: SQLite returns an empty database rather than replaying the orphan, so the state is benign. The hypothesised failure did not reproduce — but the branch measures emptiness anyway, so a future SQLite or schema change that did resurrect rows would hit Q25’s throw rather than certify them. The test pins the observed behaviour and says why the code does not depend on it.
  • V3 marker, no store. Q13 makes v3StoreURL == v4StoreURL, so this is a certified M3 library whose file is gone. Creating a replacement would certify it and delete the marker — destroying the only record that a populated library ever existed, in a codebase whose CLAUDE.md leads with “preserve evidence before acting.” Now throws with a restore-from-backup message, matching the two adjacent guards.
  • Capability gate. confirmStartEmpty enforced gate == .m4 on the transition it performed. openV4ForApp never had such a check, so deleting the function silently dropped it for the empty-creation path. Restored at the publication site rather than function entry — gating all of openV4ForApp would newly gate the already-ready open path, which was never gated and is not this change’s business.
  • Lease coverage. The deleted confirmStartEmptyReacquiresLock was the only test proving a readiness publication happens under a held exclusive lease (Req 1.19). The transition moved into the bootstrap; the test moved with it. Note the subtlety in the new test: config() returns a TempDir whose deinit removes the directory, and binding it to _ made the lock’s parent vanish before acquire — the neighbouring tests survive that only because openV4ForApp recreates directories.

Architecture impact

Removing .setupRequired collapses a tri-state startup into ready-or-throw, which is what openV4ForExtension already looked like. First launch drops from three ModelContainer opens and three lease acquisitions to one of each, and two full validateV4Store passes to zero (vacuous on an empty graph — the reasoning Q31 had already recorded for the call site this branch deletes).

V4OpeningResult is now a single-case enum, and every call site destructures it away. Deleting it and returning LibraryRecordCounts directly is the honest end state and was not done here: it would touch the extension and every remaining test for zero behavioural gain, on a branch already at −900 lines. Logged rather than smuggled in.

Important changes — detailed

openV4ForApp: certify an empty store at creation, measured not assumed

Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+V4Bootstrap.swift

Why it matters. The whole point of the branch. Closes the window in which an unmarked store could be filled and become permanently unopenable, without weakening the fail-closed guarantee that window's throw exists to provide.

What to look at. LibraryRepository+V4Bootstrap.swift:120-190 — the merged empty-store path

Takeaway. When a durable on-disk fact is gated behind a UI event, ask what the wait is actually verifying. Here the answer was nothing, so the wait was pure risk. Publish the fact when it becomes true.
Rationale. A store this process just created has no migration to complete and no content to validate, so the deferred confirmation established nothing. Emptiness is still measured via v3Counts rather than assumed, so the certification does not rest on 'we just made it' — anything unexpected falls through to Q25's throw.

Fail closed when a V3 marker outlives its store

Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+V4Bootstrap.swift

Why it matters. Found in review. The first commit silently created an empty library over a certified-but-missing one and deleted the marker — the last evidence it existed. Directly contradicted two guards 55 lines above it.

What to look at. LibraryRepository+V4Bootstrap.swift:63-73 — the new guard, mirroring the v4Marker and sidecar cases

Takeaway. When adding a branch to a state machine, check it against the neighbouring branches' handling of the same evidence shape. Three 'marker without store' cases existed; two failed closed and the new one did not.
Rationale. Q13 makes v3StoreURL and v4StoreURL the same file, so this state is a reader's certified M3 library whose file is gone. CLAUDE.md's standing rule is to preserve evidence before acting; a restore is irreversible.

Restore the .m4 capability gate on readiness publication

Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+V4Bootstrap.swift

Why it matters. Deleting confirmStartEmpty removed the last enforcement of gate == .m4 on the empty-creation transition. No caller passes a lower gate today, so this is defence in depth rather than a live bug.

What to look at. LibraryRepository+V4Bootstrap.swift:176-181

Takeaway. When deleting a function, enumerate its side effects rather than just its return value — lock acquisition, gating, and cleanup are easy to lose because nothing references them.
Rationale. Scoped to the publication site rather than function entry: gating all of openV4ForApp would newly gate the already-ready open path, which was never gated and is outside this change.

Delete the first-run setup surface; keep Settings import intact

Asterism/Asterism/ViewModels/AppLibraryModel.swift

Why it matters. T-1919. Removes a tri-state startup and ~830 lines of UI, model and test. The distinction that matters: the onboarding *choice* goes, the import *capability* stays — it is the defined path for moving data out of CloudKit's development environment before any TestFlight build.

What to look at. AppLibraryModel.swift:153-165, ContentView.swift (setupRequired branch), two files renamed to match what remains in them

Takeaway. Deleting a feature is a good moment to check which of its parts other things depend on. Here the screen and the importer looked like one unit and were not.
Rationale. With the marker published at creation there is nothing left to confirm, so the screen asked a question whose outcomes had become identical.

Record the reversal in the spec that owns the decision

specs/unified-teaching-composition/decision_log.md

Why it matters. Review's strongest non-code finding: the branch reverses a deliberately-locked decision, invalidates six M3 requirements, and accepts a named regression — and originally said so in code comments and a commit message only.

What to look at. Decision 9 (full Nygard entry, four rejected alternatives), the two amended bootstrap state-table rows, the annotated Q31, and the OVERVIEW carry-forward note

Takeaway. A code comment is the wrong home for a decision that reverses another document. Put it where the reader who doubts you will actually look — the spec that owns the invariant.
Rationale. Q25 reads as violated by this change unless the empty/nonempty narrowing is stated somewhere durable. Historical M3 spec docs are left as the record; the superseding note points forward instead of rewriting them.

Restore lease coverage and repair a test that had stopped testing its name

Packages/AsterismCore/Tests/AsterismCoreTests/BackupImportTransactionTests.swift

Why it matters. Two coverage regressions the diff hid. fillEmptyStaleWhenNonempty set up an *unmarked* store, so after the guard change it short-circuited on the marker check and silently duplicated the test below it, leaving fill-empty's counts == .zero guard uncovered.

What to look at. BackupImportTransactionTests.swift:87-106; V4MigrationBootstrapTests.swift:412-430 (lease test)

Takeaway. A passing test whose setup no longer reaches its assertion is worse than a missing one — it reports coverage that does not exist. Changing a guard's ordering can silently reroute every test that passed through it.
Rationale. The deleted confirmStartEmptyReacquiresLock was the only proof that readiness publication happens under a held exclusive lease (Req 1.19); the transition moved into the bootstrap, so the test moved with it.

Key decisions

Mark an empty store at creation rather than deferring to a confirmation.

Four alternatives were weighed and rejected: keep the choice but mark eagerly (the screen's outcomes become identical); mark on first write (narrows the window rather than closing it, and the extension cannot mark because it must never migrate); treat any empty unmarked store as ready without measuring (reintroduces exactly the assumption Q25 forbids); fail closed on empty unmarked (turns a benign self-healing state into data loss and bricks every library created by an earlier build). Recorded as Decision 9.

Give up the extension's fail-closed window deliberately.

The extension can now capture into a fresh library before a backup restore, and a replace-style import would discard that capture. T-1969 explicitly asked for this to be a conscious decision rather than a side effect. Accepted: the guard was incidental to a screen being removed, the alternative is a permanently unopenable library, and the replace path still shows current record counts behind a two-step destructive confirmation — so the capture is confirmed away, not silently lost. Recorded in Decision 9's Consequences and pinned by extensionBlockedUntilFirstAppLaunch.

Leave the M3 spec documents unchanged; point forward instead.

Six requirements in specs/url-identity-re-share/ are superseded. Those documents are the record of what M3 shipped, so rewriting them would falsify history. The carry-forward note in specs/OVERVIEW.md lists the superseded requirement numbers and points at Decision 9. The distinction drawn: unified-teaching-composition owns the bootstrap state table and Q25, so its table was amended in place; url-identity-re-share merely described a flow that has since been deleted.

Remove what this change makes dead; leave what was already dead.

The principle used to bound scope on a change that could have cascaded indefinitely. confirmStartEmpty, V4OpeningResult.setupRequired and SetupOrReadyEmptyState became unreachable because of this change, so they went. BackupImportCommitMode was already unused but referenced a deleted type, so it went as a consequence. Pre-existing duplication in unrelated test helpers was left alone.

Do not collapse the single-case V4OpeningResult in this branch.

Two reviewers independently recommended deleting V4OpeningResult and V4ExtensionResult and returning LibraryRecordCounts directly, noting the symmetry argument is weak because both types are structurally identical and neither is read by any production caller. Correct, and deferred: it touches the share extension and every remaining test for zero behavioural gain, on a branch already at −900 lines.

Review findings

SeverityAreaFindingResolution
majorLibraryRepository+V4Bootstrap.swift — V3 marker without storeA V3 readiness marker with no store file reached the create-and-certify branch. Since V3 and V4 share the store path (Q13), that state is a certified M3 library whose file is gone: the code created an empty replacement, published a V4 marker over it, and deleted the V3 marker — destroying the only evidence a populated library existed. Directly contradicted the v4Marker-without-store and sidecar-without-store guards in the same function, which both fail closed.Added a matching guard that throws with a restore-from-backup message and preserves the V3 marker. Pinned by v3MarkerWithoutStoreFailsLoud, which asserts the marker survives.
majorBackupImportTransactionTests — fillEmptyStaleWhenNonemptyThe test set up a nonempty *unmarked* store, but the new `guard storeExists, markerExists` short-circuits first — so it returned stale for the unmarked reason, silently becoming a duplicate of the test below it. Left the `counts == .zero` guard in confirmImportFillEmpty with no coverage at all; no other call site in the suite sets up a marked-and-nonempty store.Switched to createReadyPopulatedV3Store (marker + records) so it reaches the emptiness check, with a comment explaining why the setup must be marked. Removed createPopulatedUnmarkedV3Store, now unused.
majorspecs/ — an overturned decision recorded nowhereThe branch narrows Q25, contradicts two rows of the bootstrap state table in the spec that owns it, invalidates six M3 requirements, orphans Q31 (which names the deleted confirmStartEmpty), and accepts a named behavioural regression — while touching zero files under specs/. A future reader hitting publishV4Readiness on an unmarked store would read Q25 and reasonably conclude it had been violated.Added Decision 9 as a full Enhanced Nygard entry with four rejected alternatives and the regression under Consequences; amended the two state-table rows in place; annotated Q31; added a carry-forward note to specs/OVERVIEW.md listing the superseded requirement numbers.
majorTest coverage — lease on readiness publication (Req 1.19)Deleting confirmStartEmpty removed the only test proving a readiness publication occurs under a held exclusive lease. The transition moved into openV4ForApp and no test held the lock against it; every other lock-holding test in the repo covers a different entry point.Added markAtBirthHoldsExclusiveLease. Required binding the TempDir returned by config() — discarding it with `_` let deinit remove the lock's parent directory before acquire, which the neighbouring tests survive only because openV4ForApp recreates directories.
minorLibraryRepository+V4Bootstrap.swift — dropped capability gateconfirmStartEmpty enforced `capabilities.gate == .m4` before publishing readiness. openV4ForApp has no such check, so deleting the function removed the last enforcement of it on the empty-creation transition. No caller passes a lower gate today, so there is no live failure.Restored at the publication site, not function entry — gating all of openV4ForApp would newly gate the already-ready open path, which was never gated. Pinned by markAtBirthRequiresM4Gate.
minorLibraryRepository+V4Bootstrap.swift — emptiness assumed on the created branchThe first commit published the marker unconditionally on the just-created branch. storeExists tests only the .sqlite, so 'just created' really means 'the main file was absent a moment ago' — the branch was certifying without ever measuring, which is the assumption Q25 exists to forbid.Both empty-store branches now converge on one `counts == .zero` check after creation, so anything unexpected falls through to the unverifiable-partial throw. This also removed the duplicated publish-and-return tail that the reuse reviewer flagged.
minorTest — orphan-WAL hypothesis did not reproduceThe efficiency reviewer hypothesised that a partial restore leaving -wal/-shm without the .sqlite would let a 'fresh' container replay content and get certified. A test written to prove it failed: SQLite returns an empty database and does not replay the orphan.Kept the test, inverted to pin the observed safe behaviour, and reworded the code comment so it no longer asserts a scenario that could not be reproduced — while noting the branch measures emptiness anyway, so the certification does not depend on it.
minorCHANGELOG.mdThe entire changelog is a single [Unreleased] section, so two 'Added' lines advertising the first-run Import/Start Empty screen and 'withholds readiness until explicit setup' would ship in release notes describing a screen that no longer exists. No entry existed for this change despite it removing a user-visible surface.Rewrote both lines to describe shipped behaviour and added a Fixed entry covering the brick, the removed screen, and the extension-capture consequence.
minorMechanical edit damageDeleting the expectedState: parameter by substring replacement clipped four continuation lines' indentation (three saveStrategy:, one capabilities:) and left a duplicated `// MARK: - Capability gating`. No formatter is configured in this repo, so nothing would catch it.All five fixed; swept every changed Swift file for non-multiple-of-4 indentation to confirm no others. Also removed a vestigial `_ = result` in AsterismStoreTestHelper.
minordocs/agent-notes/composed-teaching-ui.md described the deleted openV4ForApp → confirmStartEmpty → reopen sequence. schema-migration.md presents itself as the map of openV4ForApp and omitted the new branches entirely.Both updated. testing.md and CLAUDE.md were checked and contain no statements this branch makes false.
minorCore API — single-case V4OpeningResultWith .setupRequired gone, V4OpeningResult is a single-case enum structurally identical to V4ExtensionResult, and no production caller reads either — every call site destructures to (_, repository). The wrapper's only measurable effect was the `guard case .ready` boilerplate this diff deletes eight instances of.Deferred deliberately, not overlooked. Deleting both types touches the share extension and every remaining test for zero behavioural gain, on a branch already at −900 lines. Logged under Worth a second look.
nitTest coverage gaps left openThree gaps: no test that the marker is withheld when store creation itself fails (correct by inspection — both throws precede publishV4Readiness — but the save is a direct call, not routed through the injected saveStrategy, so it cannot be faked); no cross-process race test for mark-at-birth (the helper subcommand that would enable it has no callers); no UI test asserting a fresh launch renders an empty library rather than a setup screen (needs a new empty scenario in UITestLaunchSupport, since a scenario-less launch resolves to the real App Group).Left open. Each needs infrastructure disproportionate to this branch; every seeded UI scenario already drives the mark-at-birth path in the real app process before seeding.

Per-file diffs

Click to expand.

Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+V4Bootstrap.swift Modified +63 / -16
diff --git a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+V4Bootstrap.swift b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+V4Bootstrap.swiftindex 36b3668..9250745 100644--- a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+V4Bootstrap.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+V4Bootstrap.swift@@ -10,11 +10,13 @@ import SwiftData /// sidecar. The extension never migrates — it requires the V4 marker + store or /// fails closed. M3's "valid nonempty unmarked ⟹ ready" heuristic is not carried /// into V4: unverifiable partial-migration states fail loudly.+///+/// An *empty* store is marked ready as soon as it exists, so the app either opens+/// a ready library or throws — there is no third state for the reader to resolve. public extension LibraryRepository {     /// The result of evaluating fixed-path V4 state under an exclusive lease.     enum V4OpeningResult: Equatable, Sendable {         case ready(LibraryRecordCounts)-        case setupRequired     }      /// Extension-only readiness result for V4.@@ -24,13 +26,13 @@ public extension LibraryRepository {      /// App startup: evaluates fixed-path V4 state under one exclusive lease,     /// migrates in place when required, and certifies readiness. Implements the-    /// design's bootstrap state table; the lease is released before setup UI.+    /// design's bootstrap state table. Returns an open, ready library or throws.     static func openV4ForApp(         _ configuration: LibraryConfiguration,         capabilities: AsterismCapabilities = .current,         clock: any RepositoryClock = SystemRepositoryClock(),         saveStrategy: any RepositorySaveStrategy = ModelContextSaveStrategy()-    ) async throws -> (result: V4OpeningResult, repository: LibraryRepository?) {+    ) async throws -> (result: V4OpeningResult, repository: LibraryRepository) {         let fileManager = FileManager.default         do {             try fileManager.createDirectory(at: configuration.rootDirectory, withIntermediateDirectories: true)@@ -57,6 +59,18 @@ public extension LibraryRepository {                 operation: "opening ready V4 library",                 reason: "readiness marker exists but the V4 store is missing")         }+        // The same, one schema back. V3 and V4 share the store path (Q13), so a+        // V3 marker with no store means a certified M3 library file is gone.+        // Creating an empty one here would certify the replacement and delete+        // the last evidence that a populated library ever existed — the mistake+        // the guard above and the sidecar guard below both exist to prevent.+        // Mark-at-birth is for libraries with no history, not for lost ones.+        guard !(v3Marker && !storeExists) else {+            throw LibraryRepositoryError.libraryUnavailable(+                operation: "opening current V4 library",+                reason: "a V3 readiness marker exists but the store is missing; "+                    + "restore from a backup rather than starting an empty library")+        }         if v4Marker { try validateV4MarkerContent(at: configuration.v4MarkerURL) }          // A present sidecar must verify before it can drive anything (Q25).@@ -106,33 +120,66 @@ public extension LibraryRepository {                 capabilities: capabilities, clock: clock, saveStrategy: saveStrategy)         } +        // Create the store when it is missing, so the unmarked-store case below+        // covers first run and every later launch alike. No V3 marker can be+        // here — the guard above rejected that — so a created store carries no+        // history and the migration branch stays reachable only for a real one.+        let container: ModelContainer         if !storeExists {-            // Neither marker, no store → first-run setup: create the empty store.-            let container = try openV4Container(at: configuration.v4StoreURL)-            let context = ModelContext(container)-            do { try context.save() } catch {+            container = try openV4Container(at: configuration.v4StoreURL)+            let creationContext = ModelContext(container)+            do { try creationContext.save() } catch {                 throw LibraryRepositoryError.libraryUnavailable(                     operation: "creating the empty V4 store", reason: String(describing: error))             }-            return (.setupRequired, nil)-        }--        if v3Marker {+        } else if v3Marker {             // V3 marker + store: a ready M3 library to migrate. Write the sidecar             // from the V3 shape before any conversion, then complete.             let sidecar = try writeFreshSidecar(configuration)             return try certifyMigration(                 configuration, sidecar: sidecar,                 capabilities: capabilities, clock: clock, saveStrategy: saveStrategy)+        } else {+            container = try openV4Container(at: configuration.v4StoreURL)         } -        // Store present, no markers, no sidecar: empty resumes setup; nonempty is-        // an unverifiable partial migration (no nonempty-unmarked heuristic).-        let container = try openV4Container(at: configuration.v4StoreURL)+        // Store present, no markers, no sidecar. An *empty* one is marked ready+        // and opened — whether this call just created it, or an older build left+        // it unmarked, or a crash landed between creation and the marker.+        //+        // The marker used to wait for the reader to confirm a first-run choice,+        // which left a window where a store existed unmarked while anything+        // could write into it. Anything that fills it — CloudKit mirroring will,+        // once it is enabled — sent every later launch to the throw below, with+        // no in-app way back.+        //+        // Emptiness is *measured*, never assumed from "we just made it".+        // `storeExists` tests only the `.sqlite`, so a store this call believes+        // is new is really just one whose main file was absent a moment ago —+        // today SQLite gives back an empty database in that case rather than+        // replaying orphaned sidecars (pinned by+        // `orphanedWALDoesNotResurrectContent`), but the certification does not+        // rest on that. `counts == .zero` covers every entity kind, so anything+        // nonempty falls through to the throw — an unverifiable partial+        // migration, which fails closed and preserves the evidence (Q25). No+        // nonempty-unmarked heuristic, in either direction.         let context = ModelContext(container)         let counts = try v3Counts(context: context)         if counts == .zero {-            return (.setupRequired, nil)+            // Publishing readiness is a V4 library transition, and the gate the+            // deleted `confirmStartEmpty` enforced on it holds here instead: a+            // lower gate must not be able to certify a V4 library.+            guard capabilities.gate == .m4 else {+                throw LibraryRepositoryError.invalidInput(+                    operation: "publishing V4 readiness for an empty library",+                    reason: "V4 library transitions require the m4 capability gate, got \(capabilities.gate.rawValue)")+            }+            // Marker last: a crash before this leaves an empty unmarked store,+            // which this same branch marks on the next launch.+            try publishV4Readiness(at: configuration.v4MarkerURL)+            v4Logger.debug("Marked an empty unmarked V4 store as ready")+            return (.ready(.zero), makeRepository(+                configuration, container, capabilities, clock, saveStrategy))         }         throw LibraryRepositoryError.libraryUnavailable(             operation: "opening current V4 library",@@ -249,7 +296,7 @@ extension LibraryRepository {         capabilities: AsterismCapabilities,         clock: any RepositoryClock,         saveStrategy: any RepositorySaveStrategy-    ) throws -> (result: V4OpeningResult, repository: LibraryRepository?) {+    ) throws -> (result: V4OpeningResult, repository: LibraryRepository) {         let container: ModelContainer         do {             container = try openV4Container(at: configuration.v4StoreURL)
Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+BackupImport.swift Modified +10 / -85
diff --git a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+BackupImport.swift b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+BackupImport.swiftindex fc2ff79..29f3c01 100644--- a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+BackupImport.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+BackupImport.swift@@ -7,87 +7,20 @@ import SwiftData /// The runtime import-commit path is V4 (Decision 2, task 25): every accepted /// source version is mapped to a prospective `BackupImportV4Plan` outside the /// actor, and these methods materialize it into the fixed-path V4 store under the-/// exclusive lease, validate with `V4LibraryValidator`, and publish the V4-/// readiness marker. The frozen V2/V3 codecs and the V2→V3 / V3→V4 mappers are-/// import-only and untouched; only the live commit path switched to V4.+/// exclusive lease and validate with `V4LibraryValidator`. Both commit into an+/// already-ready library: the bootstrap publishes the readiness marker when it+/// creates the store, so import is a Settings action, never a first-run one. The+/// frozen V2/V3 codecs and the V2→V3 / V3→V4 mappers are import-only and+/// untouched; only the live commit path switched to V4. extension LibraryRepository {     private static let importLogger = Logger(subsystem: "me.nore.ig.Asterism", category: "BackupImport") -    // MARK: - Confirm Start Empty--    /// Confirms Start Empty: reacquires exclusive access, validates that the V4-    /// store is valid and empty with no readiness marker, publishes readiness,-    /// and returns the zero-count library.-    public static func confirmStartEmpty(-        _ configuration: LibraryConfiguration,-        capabilities: AsterismCapabilities = .m4,-        clock: any RepositoryClock = SystemRepositoryClock(),-        saveStrategy: any RepositorySaveStrategy = ModelContextSaveStrategy()-    ) async throws -> BackupImportCommitResult {-        Self.importLogger.debug("Confirming Start Empty")--        guard capabilities.gate == .m4 else {-            throw LibraryRepositoryError.invalidInput(-                operation: "confirming Start Empty",-                reason: "V4 library transitions require the m4 capability gate, got \(capabilities.gate.rawValue)"-            )-        }--        let fileManager = FileManager.default--        let lease = try await CrossProcessLibraryLock.acquire(-            mode: .exclusive,-            at: configuration.lockURL,-            timeout: .seconds(5)-        )-        defer { withExtendedLifetime(lease) {} }--        let markerExists = fileManager.fileExists(atPath: configuration.v4MarkerURL.path)-        let storeExists = fileManager.fileExists(atPath: configuration.v4StoreURL.path)--        Self.importLogger.debug("Start Empty: store=\(storeExists) marker=\(markerExists)")--        guard storeExists, !markerExists else {-            Self.importLogger.debug("Start Empty: state changed — store=\(storeExists) marker=\(markerExists)")-            return .stale(reason: "library state changed; expected empty unmarked V4 store")-        }--        let container: ModelContainer-        do {-            container = try openV4Container(at: configuration.v4StoreURL)-        } catch {-            throw LibraryRepositoryError.libraryUnavailable(-                operation: "opening V4 store for Start Empty",-                reason: String(describing: error)-            )-        }--        let context = ModelContext(container)-        let counts = try v3Counts(context: context)--        guard counts == .zero else {-            Self.importLogger.debug("Start Empty: library is not empty — refreshing")-            return .stale(reason: "library is not empty; expected zero records")-        }--        // Not a gate on imported content: the store is provably empty by the-        // guard above, so tolerant and strict validation agree here.-        _ = try validateV4Store(context: context)--        try publishV4Readiness(at: configuration.v4MarkerURL)-        try? fileManager.removeItem(at: configuration.v3MarkerURL)-        Self.importLogger.debug("Start Empty: readiness published")--        return .committed(.zero)-    }-     // MARK: - Confirm Import (Fill Empty) -    /// Commits a V4 import plan into an empty V4 library.+    /// Commits a V4 import plan into a ready but empty V4 library.     public static func confirmImportFillEmpty(         _ configuration: LibraryConfiguration,         plan: BackupImportV4Plan,-        expectedState: SetupOrReadyEmptyState,         capabilities: AsterismCapabilities = .m4,         clock: any RepositoryClock = SystemRepositoryClock(),         saveStrategy: any RepositorySaveStrategy = ModelContextSaveStrategy()@@ -115,15 +48,8 @@ extension LibraryRepository {          Self.importLogger.debug("Fill import: store=\(storeExists) marker=\(markerExists)") -        switch expectedState {-        case .setupRequired:-            guard storeExists, !markerExists else {-                return .stale(reason: "expected unmarked empty store, but state changed")-            }-        case .readyEmpty:-            guard storeExists, markerExists else {-                return .stale(reason: "expected ready empty store, but state changed")-            }+        guard storeExists, markerExists else {+            return .stale(reason: "expected ready empty store, but state changed")         }          let container: ModelContainer@@ -193,9 +119,8 @@ extension LibraryRepository {             )         } -        if !markerExists {-            try publishV4Readiness(at: configuration.v4MarkerURL)-        }+        // Readiness is already published — the guard above required the marker,+        // and the bootstrap publishes it when it creates the store.         try? fileManager.removeItem(at: configuration.v3MarkerURL)          Self.importLogger.debug("Fill import: committed \(materializedCounts.entries) entries")
Packages/AsterismCore/Sources/AsterismCore/BackupImporter.swift Modified +0 / -24
diff --git a/Packages/AsterismCore/Sources/AsterismCore/BackupImporter.swift b/Packages/AsterismCore/Sources/AsterismCore/BackupImporter.swiftindex 3add9d7..d93d811 100644--- a/Packages/AsterismCore/Sources/AsterismCore/BackupImporter.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/BackupImporter.swift@@ -65,30 +65,6 @@ public struct BackupImportMetadata: Sendable, Equatable {     } } -// MARK: - Commit Mode--/// Describes the expected precondition for the import commit.-///-/// For `fillEmpty`, commit reacquires exclusive access, re-reads state, requires-/// a valid empty unmarked or ready-empty graph, materializes, compares, and saves.-/// For `replace`, commit reacquires exclusive access, requires the exact displayed-/// inventory fingerprint, deletes the complete graph, inserts the plan, and saves-/// without releasing readiness.-public enum BackupImportCommitMode: Sendable, Equatable {-    /// Import into a valid empty V3 library (first-run or ready-empty).-    case fillEmpty(expectedState: SetupOrReadyEmptyState)-    /// Destructive replacement of a nonempty ready V3 library.-    case replace(expectedInventory: LibraryInventoryFingerprint)-}--/// The expected empty-library state for fill-empty commits.-public enum SetupOrReadyEmptyState: Sendable, Equatable {-    /// Setup required: valid empty store, no readiness marker.-    case setupRequired-    /// Ready but empty: valid empty store with readiness marker.-    case readyEmpty-}- /// An opaque fingerprint of the current library inventory at the time of preview. /// Used to detect stale replacement confirmation. public struct LibraryInventoryFingerprint: Sendable, Equatable {
Asterism/Asterism/ViewModels/AppLibraryModel.swift Modified +9 / -70
diff --git a/Asterism/Asterism/ViewModels/AppLibraryModel.swift b/Asterism/Asterism/ViewModels/AppLibraryModel.swiftindex 48cb63e..30dd23f 100644--- a/Asterism/Asterism/ViewModels/AppLibraryModel.swift+++ b/Asterism/Asterism/ViewModels/AppLibraryModel.swift@@ -9,8 +9,6 @@ public final class AppLibraryModel {      public enum State: Equatable, Sendable {         case loading-        /// V3 store exists and is empty but no readiness marker — require Import or Start Empty.-        case setupRequired         case ready         case unavailable(message: String)     }@@ -22,9 +20,6 @@ public final class AppLibraryModel {     /// One gate value drives repository validation, navigation, teaching, Backup, and capture UI.     public let capabilities: AsterismCapabilities -    /// The first-run setup model, available only when state == .setupRequired.-    public private(set) var setupModel: FirstRunLibrarySetupModel?-     /// Why the last diagnosis re-derivation failed, or nil when the published     /// diagnoses describe the store as it currently stands (Req 4.3).     ///@@ -132,7 +127,7 @@ public final class AppLibraryModel {         self.uiTestFixture = nil     } -    /// Attempts to open the library; transitions to ready, setupRequired, or unavailable.+    /// Attempts to open the library; transitions to ready or unavailable.     public func bootstrap() async {         state = .loading         if let startupFailureMessage {@@ -158,55 +153,13 @@ public final class AppLibraryModel {             // marker/store observation, migrates in place when required, and             // performs every startup transition while that lease is held             // (task 25 runtime switch; the composed teaching surface commits-            // against the real V4 runtime in production).-            let opening = try await LibraryRepository.openV4ForApp(+            // against the real V4 runtime in production). A first run creates+            // the store and marks it ready, so there is no setup step to route+            // around here — for production or for a UI test's disposable root.+            var repo = try await LibraryRepository.openV4ForApp(                 configuration,                 capabilities: capabilities-            )-            var repo: LibraryRepository-            switch opening.result {-            case .setupRequired where uiTestFixture != nil:-                // Explicit UI-test launches use an isolated disposable root.-                // Confirm readiness through the same locked production action,-                // then reopen V4 before seeding the requested fixture.-                let startResult = try await LibraryRepository.confirmStartEmpty(-                    configuration,-                    capabilities: capabilities-                )-                guard case .committed = startResult else {-                    throw LibraryRepositoryError.libraryUnavailable(-                        operation: "preparing UI test V4 library",-                        reason: "the isolated empty-library confirmation became stale"-                    )-                }-                let readyOpening = try await LibraryRepository.openV4ForApp(-                    configuration,-                    capabilities: capabilities-                )-                guard let openedRepository = readyOpening.repository else {-                    throw LibraryRepositoryError.libraryUnavailable(-                        operation: "opening UI test V4 library",-                        reason: "readiness was published without an open repository"-                    )-                }-                repo = openedRepository-            case .setupRequired:-                Self.logger.debug("V4 library requires explicit first-run setup")-                setupModel = FirstRunLibrarySetupModel(-                    configuration: configuration,-                    capabilities: capabilities-                )-                state = .setupRequired-                return-            case .ready:-                guard let openedRepository = opening.repository else {-                    throw LibraryRepositoryError.libraryUnavailable(-                        operation: "opening ready V4 library",-                        reason: "the V4 opener returned no repository"-                    )-                }-                repo = openedRepository-            }+            ).repository              if let uiTestFixture {                 try await seedUITestFixture(uiTestFixture, in: repo)@@ -218,17 +171,10 @@ public final class AppLibraryModel {                     // exists. A refresh is not enough: `LibraryToleranceScan`                     // cannot produce `.siteTuple`, which only the full                     // `validate(graph:)` at open derives.-                    let reopened = try await LibraryRepository.openV4ForApp(+                    repo = try await LibraryRepository.openV4ForApp(                         configuration,                         capabilities: capabilities-                    )-                    guard let reopenedRepository = reopened.repository else {-                        throw LibraryRepositoryError.libraryUnavailable(-                            operation: "reopening the seeded UI test library",-                            reason: "readiness was published without an open repository"-                        )-                    }-                    repo = reopenedRepository+                    ).repository                 }             }             self.repository = repo@@ -242,13 +188,6 @@ public final class AppLibraryModel {         }     } -    /// Called when first-run setup completes (Import or Start Empty). Re-bootstraps the library.-    public func handleSetupComplete() async {-        Self.logger.debug("First-run setup completed — re-bootstrapping")-        setupModel = nil-        await bootstrap()-    }-     /// Retries the same bootstrap without changing paths.     public func retry() async {         await bootstrap()@@ -500,7 +439,7 @@ public final class AppLibraryModel {          if fixture == .composed {             // Production now opens V4, so the composed fixture seeds through the-            // ordinary V4 setup path (openV4ForApp → confirmStartEmpty → reopen).+            // ordinary V4 bootstrap, which creates and marks the empty store.             try await seedComposedFixture(in: repository)             return         }
Asterism/Asterism/ContentView.swift Modified +0 / -9
diff --git a/Asterism/Asterism/ContentView.swift b/Asterism/Asterism/ContentView.swiftindex d944816..fba19b7 100644--- a/Asterism/Asterism/ContentView.swift+++ b/Asterism/Asterism/ContentView.swift@@ -60,15 +60,6 @@ struct ContentView: View {                 ProgressView("Opening library…")                     .accessibilityIdentifier("app-loading") -            case .setupRequired:-                if let setupModel = model.setupModel {-                    FirstRunLibrarySetupView(model: setupModel) {-                        Task { await model.handleSetupComplete() }-                    }-                } else {-                    ProgressView("Preparing setup…")-                }-             case .unavailable(let message):                 ContentUnavailableView {                     Label("Library Unavailable", systemImage: "exclamationmark.triangle")
Asterism/Asterism/ViewModels/SettingsBackupImportModel.swift Renamed +364 / -0 (was FirstRunLibrarySetupModel.swift)
diff --git a/Asterism/Asterism/ViewModels/SettingsBackupImportModel.swift b/Asterism/Asterism/ViewModels/SettingsBackupImportModel.swiftnew file mode 100644index 0000000..06c24a1--- /dev/null+++ b/Asterism/Asterism/ViewModels/SettingsBackupImportModel.swift@@ -0,0 +1,364 @@+import AsterismCore+import Foundation+import OSLog+import UniformTypeIdentifiers++// MARK: - Document Reading Protocol++/// Test seam for reading security-scoped backup file data.+/// Production implementation validates document access and security scope.+public protocol BackupDocumentReading: Sendable {+    /// Reads the raw bytes from a security-scoped URL.+    /// Validates access/security scope and selected bytes before returning.+    func readData(from url: URL) throws -> Data+}++/// Production document reader that validates security-scoped access.+public struct SecurityScopedDocumentReader: BackupDocumentReading, Sendable {+    public nonisolated init() {}++    public func readData(from url: URL) throws -> Data {+        guard url.startAccessingSecurityScopedResource() else {+            throw BackupDocumentError.securityScopeAccessDenied(url: url)+        }+        defer { url.stopAccessingSecurityScopedResource() }++        let data = try Data(contentsOf: url)+        guard !data.isEmpty else {+            throw BackupDocumentError.emptyFile(url: url)+        }+        return data+    }+}++/// Errors from document reading.+public enum BackupDocumentError: Error, Equatable, Sendable, CustomStringConvertible {+    case securityScopeAccessDenied(url: URL)+    case emptyFile(url: URL)+    case readFailed(reason: String)++    public var description: String {+        switch self {+        case .securityScopeAccessDenied:+            "Cannot access the selected file. Please try selecting it again."+        case .emptyFile:+            "The selected file is empty."+        case .readFailed(let reason):+            "Failed to read file: \(reason)"+        }+    }+}++// MARK: - Backup Import Committing Protocol++/// Test seam for the Core import commit operations.+/// Abstracts LibraryRepository static methods so tests can inject fakes.+public protocol BackupImportCommitting: Sendable {+    func confirmImportFillEmpty(+        _ configuration: LibraryConfiguration,+        plan: BackupImportV4Plan,+        capabilities: AsterismCapabilities+    ) async throws -> BackupImportCommitResult++    func confirmImportReplace(+        _ configuration: LibraryConfiguration,+        plan: BackupImportV4Plan,+        expectedInventory: LibraryInventoryFingerprint,+        capabilities: AsterismCapabilities+    ) async throws -> BackupImportCommitResult++    func computeInventoryFingerprint(+        configuration: LibraryConfiguration+    ) async throws -> LibraryInventoryFingerprint+}++/// Production implementation that calls through to LibraryRepository.+public struct LibraryImportCommitter: BackupImportCommitting, Sendable {+    public nonisolated init() {}++    public func confirmImportFillEmpty(+        _ configuration: LibraryConfiguration,+        plan: BackupImportV4Plan,+        capabilities: AsterismCapabilities+    ) async throws -> BackupImportCommitResult {+        try await LibraryRepository.confirmImportFillEmpty(+            configuration,+            plan: plan,+            capabilities: capabilities+        )+    }++    public func confirmImportReplace(+        _ configuration: LibraryConfiguration,+        plan: BackupImportV4Plan,+        expectedInventory: LibraryInventoryFingerprint,+        capabilities: AsterismCapabilities+    ) async throws -> BackupImportCommitResult {+        try await LibraryRepository.confirmImportReplace(+            configuration,+            plan: plan,+            expectedInventory: expectedInventory,+            capabilities: capabilities+        )+    }++    public func computeInventoryFingerprint(+        configuration: LibraryConfiguration+    ) async throws -> LibraryInventoryFingerprint {+        try await LibraryRepository.computeInventoryFingerprint(+            configuration: configuration+        )+    }+}++// MARK: - SettingsBackupImportModel++/// Drives the Settings backup import surface for nonempty libraries.+/// Supports both import into a ready-empty library and destructive replacement+/// of a nonempty library with preview and separate confirmation. (Req 1.11)+///+/// Only calls Core confirmation after picker/preview interaction.+@MainActor @Observable+public final class SettingsBackupImportModel {+    private static let logger = Logger(subsystem: "me.nore.ig.Asterism", category: "SettingsImport")++    // MARK: - State++    public enum State: Equatable, Sendable {+        /// Idle — Import Backup action available.+        case idle+        /// Document picker is being presented.+        case pickingDocument+        /// Reading and decoding the selected backup.+        case decodingBackup+        /// Import plan ready — for empty library, simple fill confirmation.+        case readyToFill(preview: FillPreview)+        /// Import plan ready — for nonempty library, destructive replacement preview.+        case readyToReplace(preview: ReplacePreview)+        /// Confirming destructive replacement (second confirmation step).+        case confirmingReplace(preview: ReplacePreview)+        /// Committing (fill or replace in progress).+        case committing+        /// Import completed successfully.+        case completed(LibraryRecordCounts)+        /// An error occurred.+        case failed(message: String)+    }++    /// Preview for fill-empty import.+    public struct FillPreview: Equatable, Sendable {+        public let metadata: BackupImportMetadata+        public let importCounts: LibraryRecordCounts+    }++    /// Preview for destructive replacement.+    public struct ReplacePreview: Equatable, Sendable {+        public let metadata: BackupImportMetadata+        public let importCounts: LibraryRecordCounts+        public let currentCounts: LibraryRecordCounts+        public let inventory: LibraryInventoryFingerprint+    }++    public private(set) var state: State = .idle++    // MARK: - Dependencies++    private let configuration: LibraryConfiguration+    private let capabilities: AsterismCapabilities+    private let documentReader: any BackupDocumentReading+    private let committer: any BackupImportCommitting+    private let onCompletion: @Sendable () async -> Void++    /// The plan retained between preview and confirmation.+    private var currentPlan: BackupImportV4Plan?++    // MARK: - Init++    public init(+        configuration: LibraryConfiguration,+        capabilities: AsterismCapabilities = .current,+        documentReader: any BackupDocumentReading = SecurityScopedDocumentReader(),+        committer: any BackupImportCommitting = LibraryImportCommitter(),+        onCompletion: @escaping @Sendable () async -> Void+    ) {+        self.configuration = configuration+        self.capabilities = capabilities+        self.documentReader = documentReader+        self.committer = committer+        self.onCompletion = onCompletion+    }++    // MARK: - Actions++    /// Opens the document picker.+    public func beginImport() {+        Self.logger.debug("Settings import: presenting document picker")+        state = .pickingDocument+    }++    /// Called when picker is cancelled — returns to idle with zero writes.+    public func handlePickerCancellation() {+        Self.logger.debug("Settings import: picker cancelled")+        state = .idle+        currentPlan = nil+    }++    /// Called when a file is selected. Reads, decodes, and determines fill vs replace mode.+    public func handleDocumentSelection(_ url: URL) async {+        Self.logger.debug("Settings import: document selected")+        state = .decodingBackup++        do {+            let data = try documentReader.readData(from: url)+            let plan = try BackupImporter.planV4(from: data)+            currentPlan = plan++            // Determine if library is empty or nonempty+            let fingerprint = try await committer.computeInventoryFingerprint(+                configuration: configuration+            )++            if fingerprint.counts == .zero {+                // Empty library — simple fill+                Self.logger.debug("Settings import: empty library — fill mode")+                state = .readyToFill(preview: FillPreview(+                    metadata: plan.metadata,+                    importCounts: plan.counts+                ))+            } else {+                // Nonempty library — destructive replacement required (Req 1.11)+                Self.logger.debug("Settings import: nonempty library — replacement mode")+                state = .readyToReplace(preview: ReplacePreview(+                    metadata: plan.metadata,+                    importCounts: plan.counts,+                    currentCounts: fingerprint.counts,+                    inventory: fingerprint+                ))+            }+        } catch let error as BackupDocumentError {+            Self.logger.error("Settings import: document read failed: \(String(describing: error))")+            state = .failed(message: error.description)+        } catch let error as BackupImportError {+            Self.logger.error("Settings import: planning failed: \(String(describing: error))")+            state = .failed(message: error.description)+        } catch {+            Self.logger.error("Settings import: unexpected error: \(String(describing: error))")+            state = .failed(message: "Import failed: \(error.localizedDescription)")+        }+    }++    /// Confirms fill-empty import.+    public func confirmFillImport() async {+        guard let plan = currentPlan else {+            state = .failed(message: "No import plan available.")+            return+        }++        Self.logger.debug("Settings import: confirming fill")+        state = .committing++        do {+            let result = try await committer.confirmImportFillEmpty(+                configuration,+                plan: plan,+                capabilities: capabilities+            )++            switch result {+            case .committed(let counts):+                Self.logger.debug("Settings import: fill committed")+                state = .completed(counts)+                await onCompletion()+            case .stale(let reason):+                Self.logger.debug("Settings import: fill stale — \(reason)")+                state = .failed(message: "Library state changed: \(reason). Please try again.")+            }+        } catch {+            Self.logger.error("Settings import: fill commit failed: \(String(describing: error))")+            state = .failed(message: "Import failed: \(error.localizedDescription)")+        }+    }++    /// Moves to the destructive replacement confirmation step (second confirm).+    public func proceedToReplaceConfirmation() {+        guard case .readyToReplace(let preview) = state else { return }+        Self.logger.debug("Settings import: proceeding to replacement confirmation")+        state = .confirmingReplace(preview: preview)+    }++    /// Confirms destructive replacement. Requires exact inventory match. (Req 1.22)+    public func confirmReplace() async {+        let inventory: LibraryInventoryFingerprint+        switch state {+        case .confirmingReplace(let preview):+            inventory = preview.inventory+        default:+            state = .failed(message: "Replace not in correct state.")+            return+        }++        guard let plan = currentPlan else {+            state = .failed(message: "No import plan available.")+            return+        }++        Self.logger.debug("Settings import: confirming destructive replacement")+        state = .committing++        do {+            let result = try await committer.confirmImportReplace(+                configuration,+                plan: plan,+                expectedInventory: inventory,+                capabilities: capabilities+            )++            switch result {+            case .committed(let counts):+                Self.logger.debug("Settings import: replacement committed")+                state = .completed(counts)+                await onCompletion()+            case .stale(let reason):+                // Inventory changed — refresh (Req 1.22)+                Self.logger.debug("Settings import: replacement stale — \(reason)")+                // Refresh the fingerprint and re-present+                do {+                    let freshFingerprint = try await committer.computeInventoryFingerprint(+                        configuration: configuration+                    )+                    state = .readyToReplace(preview: ReplacePreview(+                        metadata: plan.metadata,+                        importCounts: plan.counts,+                        currentCounts: freshFingerprint.counts,+                        inventory: freshFingerprint+                    ))+                } catch {+                    state = .failed(message: "Failed to refresh library state: \(error.localizedDescription)")+                }+            }+        } catch {+            Self.logger.error("Settings import: replace commit failed: \(String(describing: error))")+            state = .failed(message: "Replacement failed: \(error.localizedDescription)")+        }+    }++    /// Cancels and returns to idle.+    public func cancel() {+        Self.logger.debug("Settings import: cancelled")+        state = .idle+        currentPlan = nil+    }++    /// Returns to idle after an error.+    public func retry() {+        Self.logger.debug("Settings import: retrying")+        state = .idle+        currentPlan = nil+    }++    /// Dismisses the completed state.+    public func dismiss() {+        state = .idle+        currentPlan = nil+    }+}
Asterism/Asterism/Views/SettingsBackupImportView.swift Renamed +283 / -0 (was FirstRunLibrarySetupView.swift)
diff --git a/Asterism/Asterism/Views/SettingsBackupImportView.swift b/Asterism/Asterism/Views/SettingsBackupImportView.swiftnew file mode 100644index 0000000..d4d0396--- /dev/null+++ b/Asterism/Asterism/Views/SettingsBackupImportView.swift@@ -0,0 +1,283 @@+import AsterismCore+import SwiftUI+import UniformTypeIdentifiers++// MARK: - Settings Backup Import View++/// Settings surface for importing a backup into a ready V3 library.+/// Supports fill-empty (for ready-empty) and destructive replacement (for nonempty).+struct SettingsBackupImportView: View {+    @State private var model: SettingsBackupImportModel+    @State private var showingDocumentPicker = false++    init(model: SettingsBackupImportModel) {+        _model = State(initialValue: model)+    }++    var body: some View {+        Group {+            switch model.state {+            case .idle:+                idleView+            case .pickingDocument:+                idleView  // Picker shown as sheet+            case .decodingBackup:+                decodingView+            case .readyToFill(let preview):+                fillPreviewView(preview)+            case .readyToReplace(let preview):+                replacePreviewView(preview)+            case .confirmingReplace(let preview):+                replaceConfirmationView(preview)+            case .committing:+                committingView+            case .completed(let counts):+                completedView(counts)+            case .failed(let message):+                failedView(message)+            }+        }+        .sheet(isPresented: $showingDocumentPicker) {+            BackupDocumentPicker { url in+                Task { await model.handleDocumentSelection(url) }+            } onCancel: {+                model.handlePickerCancellation()+            }+        }+        .onChange(of: model.state) { _, newState in+            if case .pickingDocument = newState {+                showingDocumentPicker = true+            }+        }+    }++    // MARK: - Idle++    private var idleView: some View {+        Button {+            model.beginImport()+        } label: {+            Label("Import Backup", systemImage: "arrow.down.doc")+                .frame(minHeight: AsterismLayout.minHitTarget)+        }+        .accessibilityIdentifier("settings-import-backup-button")+    }++    // MARK: - Decoding++    private var decodingView: some View {+        HStack {+            ProgressView()+            Text("Reading backup…")+                .foregroundStyle(.secondary)+        }+        .accessibilityIdentifier("settings-import-decoding")+    }++    // MARK: - Fill Preview++    private func fillPreviewView(_ preview: SettingsBackupImportModel.FillPreview) -> some View {+        VStack(spacing: 16) {+            VStack(alignment: .leading, spacing: 8) {+                Text("Import \(preview.importCounts.entries) entries, \(preview.importCounts.works) works?")+                    .font(.headline)+                Text("Exported \(preview.metadata.exportedAt.formatted(date: .abbreviated, time: .shortened))")+                    .font(.caption)+                    .foregroundStyle(.secondary)+            }++            HStack(spacing: 12) {+                Button("Import") {+                    Task { await model.confirmFillImport() }+                }+                .buttonStyle(.borderedProminent)+                .frame(minWidth: AsterismLayout.minHitTarget, minHeight: AsterismLayout.minHitTarget)+                .accessibilityIdentifier("settings-confirm-fill-button")++                Button("Cancel") { model.cancel() }+                    .frame(minHeight: AsterismLayout.minHitTarget)+                    .accessibilityIdentifier("settings-cancel-fill-button")+            }+        }+        .accessibilityIdentifier("settings-import-fill-preview")+    }++    // MARK: - Replace Preview++    private func replacePreviewView(_ preview: SettingsBackupImportModel.ReplacePreview) -> some View {+        VStack(spacing: 16) {+            // Warning banner (Req 1.11)+            HStack {+                Image(systemName: "exclamationmark.triangle")+                    .foregroundStyle(.orange)+                Text("Replace Library from Backup")+                    .font(.headline)+            }+            .accessibilityElement(children: .combine)+            .accessibilityLabel("Warning: Replace Library from Backup")++            VStack(alignment: .leading, spacing: 8) {+                Text("Current library: \(preview.currentCounts.entries) entries, \(preview.currentCounts.works) works")+                    .font(.subheadline)+                Text("Import: \(preview.importCounts.entries) entries, \(preview.importCounts.works) works")+                    .font(.subheadline)+                Text("Every current V3 record will be discarded. No merge occurs.")+                    .font(.caption)+                    .foregroundStyle(.red)+            }+            .accessibilityElement(children: .combine)+            .accessibilityLabel("Current library has \(preview.currentCounts.entries) entries and \(preview.currentCounts.works) works. Import has \(preview.importCounts.entries) entries and \(preview.importCounts.works) works. Every current record will be discarded.")++            HStack(spacing: 12) {+                Button("Replace Library…") {+                    model.proceedToReplaceConfirmation()+                }+                .buttonStyle(.borderedProminent)+                .tint(.red)+                .frame(minWidth: AsterismLayout.minHitTarget, minHeight: AsterismLayout.minHitTarget)+                .accessibilityIdentifier("settings-replace-proceed-button")++                Button("Cancel") { model.cancel() }+                    .frame(minHeight: AsterismLayout.minHitTarget)+                    .accessibilityIdentifier("settings-replace-cancel-button")+            }+        }+        .accessibilityIdentifier("settings-import-replace-preview")+    }++    // MARK: - Replace Confirmation (second step)++    private func replaceConfirmationView(_ preview: SettingsBackupImportModel.ReplacePreview) -> some View {+        VStack(spacing: 20) {+            Image(systemName: "exclamationmark.triangle.fill")+                .font(.system(size: 36))+                .foregroundStyle(.red)+                .accessibilityHidden(true)++            Text("Replace entire library?")+                .font(.headline)++            Text("This will permanently discard all \(preview.currentCounts.entries) current entries and \(preview.currentCounts.works) current works and replace them with the imported backup.")+                .font(.subheadline)+                .foregroundStyle(.secondary)+                .multilineTextAlignment(.center)+                .padding(.horizontal)++            HStack(spacing: 12) {+                Button("Replace") {+                    Task { await model.confirmReplace() }+                }+                .buttonStyle(.borderedProminent)+                .tint(.red)+                .frame(minWidth: AsterismLayout.minHitTarget, minHeight: AsterismLayout.minHitTarget)+                .accessibilityIdentifier("settings-confirm-replace-button")+                .accessibilityLabel("Confirm destructive replacement of entire library")++                Button("Cancel") { model.cancel() }+                    .frame(minHeight: AsterismLayout.minHitTarget)+                    .accessibilityIdentifier("settings-replace-final-cancel-button")+            }+        }+        .accessibilityIdentifier("settings-import-replace-confirmation")+    }++    // MARK: - Committing++    private var committingView: some View {+        HStack {+            ProgressView()+            Text("Importing…")+                .foregroundStyle(.secondary)+        }+        .accessibilityIdentifier("settings-import-committing")+    }++    // MARK: - Completed++    private func completedView(_ counts: LibraryRecordCounts) -> some View {+        VStack(spacing: 12) {+            HStack {+                Image(systemName: "checkmark.circle.fill")+                    .foregroundStyle(.green)+                Text("Import complete")+            }+            Text("\(counts.entries) entries, \(counts.works) works imported.")+                .font(.caption)+                .foregroundStyle(.secondary)++            Button("Done") { model.dismiss() }+                .frame(minHeight: AsterismLayout.minHitTarget)+                .accessibilityIdentifier("settings-import-done-button")+        }+        .accessibilityIdentifier("settings-import-completed")+    }++    // MARK: - Failed++    private func failedView(_ message: String) -> some View {+        VStack(spacing: 12) {+            HStack {+                Image(systemName: "exclamationmark.triangle.fill")+                    .foregroundStyle(.red)+                Text("Import Failed")+            }+            Text(message)+                .font(.caption)+                .foregroundStyle(.secondary)+                .accessibilityIdentifier("settings-import-error-message")++            Button("Try Again") { model.retry() }+                .frame(minHeight: AsterismLayout.minHitTarget)+                .accessibilityIdentifier("settings-import-retry-button")+        }+        .accessibilityIdentifier("settings-import-failed")+    }+}++// MARK: - Document Picker (UIKit Bridge)++/// Wraps UIDocumentPickerViewController for selecting a backup file.+/// Validates that a URL with security scope is available.+struct BackupDocumentPicker: UIViewControllerRepresentable {+    let onSelection: (URL) -> Void+    let onCancel: () -> Void++    func makeCoordinator() -> Coordinator {+        Coordinator(onSelection: onSelection, onCancel: onCancel)+    }++    func makeUIViewController(context: Context) -> UIDocumentPickerViewController {+        // Accept JSON files for backup import+        let picker = UIDocumentPickerViewController(+            forOpeningContentTypes: [UTType.json],+            asCopy: false+        )+        picker.delegate = context.coordinator+        picker.allowsMultipleSelection = false+        return picker+    }++    func updateUIViewController(_ uiViewController: UIDocumentPickerViewController, context: Context) {}++    class Coordinator: NSObject, UIDocumentPickerDelegate {+        let onSelection: (URL) -> Void+        let onCancel: () -> Void++        init(onSelection: @escaping (URL) -> Void, onCancel: @escaping () -> Void) {+            self.onSelection = onSelection+            self.onCancel = onCancel+        }++        func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) {+            guard let url = urls.first else {+                onCancel()+                return+            }+            onSelection(url)+        }++        func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) {+            onCancel()+        }+    }+}
Packages/AsterismCore/Tests/AsterismCoreTests/V4MigrationBootstrapTests.swift Modified +128 / -17
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/V4MigrationBootstrapTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/V4MigrationBootstrapTests.swiftindex 795bb53..0049faf 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/V4MigrationBootstrapTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/V4MigrationBootstrapTests.swift@@ -158,8 +158,7 @@ struct V4MigrationBootstrapTests {             try makeV3Store(at: cfg, populate: populate)             try writeV3Marker(cfg) -            let (result, repository) = try await LibraryRepository.openV4ForApp(cfg)-            #expect(repository != nil, "\(name): expected a repository")+            let (result, _) = try await LibraryRepository.openV4ForApp(cfg)             guard case .ready = result else {                 Issue.record("\(name): expected .ready, got \(result)")                 continue@@ -216,8 +215,7 @@ struct V4MigrationBootstrapTests {         try makeV3Store(at: cfg) { ctx in try self.ordinaryPattern(ctx, host: "m.example", interpretationRaw: "pattern") }         try writeV3Marker(cfg) -        let (result, repository) = try await LibraryRepository.openV4ForApp(cfg)-        #expect(repository != nil)+        let (result, _) = try await LibraryRepository.openV4ForApp(cfg)         #expect(result == .ready(LibraryRecordCounts(entries: 1, works: 1, sites: 1, titlePatterns: 1, urlRulePatterns: 0)))     } @@ -229,8 +227,7 @@ struct V4MigrationBootstrapTests {         try writeV3Marker(cfg)         _ = try await LibraryRepository.openV4ForApp(cfg) -        let (result, repository) = try await LibraryRepository.openV4ForApp(cfg)-        #expect(repository != nil)+        let (result, _) = try await LibraryRepository.openV4ForApp(cfg)         guard case .ready = result else { Issue.record("expected ready"); return }     } @@ -319,25 +316,139 @@ struct V4MigrationBootstrapTests {                 "an unverifiable state must not publish the readiness marker")     } -    @Test("First run with no store returns setupRequired without a marker")-    func firstRunSetupRequired() async throws {+    // MARK: - Mark at birth (T-1969)++    @Test("First run creates the store and marks it ready at birth")+    func firstRunMarksAtBirth() async throws {+        let (_, cfg) = try config()+        let (result, _) = try await LibraryRepository.openV4ForApp(cfg)+        #expect(result == .ready(.zero))+        #expect(FileManager.default.fileExists(atPath: cfg.v4StoreURL.path))+        #expect(FileManager.default.fileExists(atPath: cfg.v4MarkerURL.path),+                "a store created by this process needs no confirmation to be marked")+    }++    @Test("An empty unmarked store is marked and opened, never left ambiguous")+    func emptyUnmarkedIsMarked() async throws {+        let (_, cfg) = try config()+        // The state a build from before mark-at-birth leaves behind, and the+        // state a crash between store creation and the marker leaves behind.+        _ = try await LibraryRepository.openV4ForApp(cfg)+        try FileManager.default.removeItem(at: cfg.v4MarkerURL)++        let (result, _) = try await LibraryRepository.openV4ForApp(cfg)+        #expect(result == .ready(.zero))+        #expect(FileManager.default.fileExists(atPath: cfg.v4MarkerURL.path))+    }++    @Test("A store filled after being marked at birth opens normally")+    func markedStoreFilledAfterBirthOpens() async throws {+        let (_, cfg) = try config()+        _ = try await LibraryRepository.openV4ForApp(cfg)+        // Stand in for CloudKit mirroring: records arrive into the fresh store.+        try insertSite(cfg, hostname: "mirrored.example")++        let (result, _) = try await LibraryRepository.openV4ForApp(cfg)+        #expect(result == .ready(LibraryRecordCounts(+            entries: 0, works: 0, sites: 1, titlePatterns: 0, urlRulePatterns: 0)))+    }++    @Test("A nonempty unmarked store still fails closed as an unverifiable partial migration")+    func nonemptyUnmarkedStillFailsClosed() async throws {         let (_, cfg) = try config()-        let (result, repository) = try await LibraryRepository.openV4ForApp(cfg)-        #expect(result == .setupRequired)-        #expect(repository == nil)+        // The pre-fix brick: an unmarked store that something filled. Marking at+        // birth closes the window, but the throw must go on meaning what it was+        // written to mean — a V3 store caught mid-migration (Q25).+        _ = try await LibraryRepository.openV4ForApp(cfg)+        try FileManager.default.removeItem(at: cfg.v4MarkerURL)+        try insertSite(cfg, hostname: "half-migrated.example")++        await #expect(throws: LibraryRepositoryError.self) {+            try await LibraryRepository.openV4ForApp(cfg)+        }         #expect(!FileManager.default.fileExists(atPath: cfg.v4MarkerURL.path))     } -    @Test("An empty unmarked store resumes setup, never certifies")-    func emptyUnmarkedResumesSetup() async throws {+    @Test("A .sqlite deleted out from under its WAL yields an empty library, not resurrected content")+    func orphanedWALDoesNotResurrectContent() async throws {         let (_, cfg) = try config()-        _ = try await LibraryRepository.openV4ForApp(cfg) // creates the empty store-        let (result, repository) = try await LibraryRepository.openV4ForApp(cfg)-        #expect(result == .setupRequired)-        #expect(repository == nil)+        // `storeExists` tests only the `.sqlite`, so a partial restore that+        // removes it while leaving the SQLite sidecars routes into the+        // create-and-mark branch. Pinning what actually happens there: SQLite+        // does not replay the orphan WAL, so the library comes up genuinely+        // empty and marking it is correct. The branch measures `counts == .zero`+        // rather than trusting "we just created it", so if a future SQLite or+        // schema change ever did resurrect rows, this would fail closed on the+        // unverifiable-partial throw instead of certifying them.+        _ = try await LibraryRepository.openV4ForApp(cfg)+        try insertSite(cfg, hostname: "resurrected.example")+        try FileManager.default.removeItem(at: cfg.v4MarkerURL)+        let sidecars = ["-wal", "-shm"].filter {+            FileManager.default.fileExists(atPath: cfg.v4StoreURL.path + $0)+        }+        try #require(!sidecars.isEmpty, "expected SQLite sidecars to exercise this path")+        try FileManager.default.removeItem(at: cfg.v4StoreURL)++        let (result, _) = try await LibraryRepository.openV4ForApp(cfg)+        #expect(result == .ready(.zero), "no row may survive the store file it lived in")+    }++    @Test("A V3 marker whose store is gone fails closed, never starts an empty library")+    func v3MarkerWithoutStoreFailsLoud() async throws {+        let (_, cfg) = try config()+        // V3 and V4 share the store path (Q13), so this is a certified M3+        // library whose file has vanished. Creating a replacement would certify+        // it and delete the marker — the last evidence it ever existed.+        try FileManager.default.createDirectory(+            at: cfg.v4StoreURL.deletingLastPathComponent(), withIntermediateDirectories: true)+        try writeV3Marker(cfg)++        await #expect(throws: LibraryRepositoryError.self) {+            try await LibraryRepository.openV4ForApp(cfg)+        }+        #expect(FileManager.default.fileExists(atPath: cfg.v3MarkerURL.path),+                "the evidence a populated library existed must be preserved")         #expect(!FileManager.default.fileExists(atPath: cfg.v4MarkerURL.path))     } +    @Test("Marking at birth happens under the exclusive lease")+    func markAtBirthHoldsExclusiveLease() async throws {+        // The TempDir must stay bound: this test takes the lock *before*+        // `openV4ForApp` runs, so unlike its neighbours it cannot rely on the+        // bootstrap recreating a root directory that deinit already removed.+        let (dir, cfg) = try config()+        // Req 1.19: readiness is published while the lease is held. The deleted+        // `confirmStartEmpty` had its own test for this; the transition moved+        // into the bootstrap, so the coverage has to move with it.+        let held = try await CrossProcessLibraryLock.acquire(+            mode: .exclusive, at: cfg.lockURL, timeout: .seconds(1))++        await #expect(throws: LibraryRepositoryError.self) {+            try await LibraryRepository.openV4ForApp(cfg)+        }+        #expect(!FileManager.default.fileExists(atPath: cfg.v4MarkerURL.path))+        withExtendedLifetime((dir, held)) {}+    }++    @Test("A sub-m4 capability gate cannot certify a fresh library")+    func markAtBirthRequiresM4Gate() async throws {+        let (_, cfg) = try config()+        await #expect(throws: LibraryRepositoryError.self) {+            try await LibraryRepository.openV4ForApp(cfg, capabilities: .m3)+        }+        #expect(!FileManager.default.fileExists(atPath: cfg.v4MarkerURL.path))+    }++    /// Writes one record directly into the V4 store, bypassing the repository —+    /// the shape a sync engine or an interrupted migration leaves behind.+    private func insertSite(_ configuration: LibraryConfiguration, hostname: String) throws {+        let container = try LibraryRepository.openV4Container(at: configuration.v4StoreURL)+        let context = ModelContext(container)+        context.insert(Site(hostname: hostname))+        try context.save()+        withExtendedLifetime(container) {}+    }+     // MARK: - Interrupted resume and exact-idempotence      @Test("A resumed completion pass creates no duplicate patterns")
Packages/AsterismCore/Tests/AsterismCoreTests/BackupImportTransactionTests.swift Modified +27 / -158
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/BackupImportTransactionTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/BackupImportTransactionTests.swiftindex 5bd7f54..9b7f600 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/BackupImportTransactionTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/BackupImportTransactionTests.swift@@ -10,7 +10,7 @@ import Testing /// publication, and file preservation. /// /// Requirements: 1.10, 1.11, 1.17, 1.18, 1.22-@Suite("Backup import/replace/start-empty transactions", .serialized)+@Suite("Backup import and replace transactions", .serialized) struct BackupImportTransactionTests {      // MARK: - Capability gating@@ -18,104 +18,20 @@ struct BackupImportTransactionTests {     @Test("Non-m4 capability gates cannot confirm V4 transitions")     func preM3GateRefused() async throws {         let env = try TestEnvironment()-        let (result, _) = try await LibraryRepository.openV4ForApp(env.configuration)-        #expect(result == .setupRequired)+        _ = try await LibraryRepository.openV4ForApp(env.configuration) +        let plan = try makeMinimalImportPlan()         await #expect(throws: LibraryRepositoryError.self) {-            _ = try await LibraryRepository.confirmStartEmpty(+            _ = try await LibraryRepository.confirmImportFillEmpty(                 env.configuration,-                capabilities: .m3+                plan: plan,+                capabilities: .m3,+                saveStrategy: ModelContextSaveStrategy()             )         }-        // The refused transition publishes nothing: setup is still required.+        // The refused transition wrote nothing: the library is still empty.         let (after, _) = try await LibraryRepository.openV4ForApp(env.configuration)-        #expect(after == .setupRequired)-    }--    // MARK: - Start Empty--    @Test("confirmStartEmpty publishes readiness for a valid empty unmarked V3 store")-    func startEmptyPublishesReadiness() async throws {-        let env = try TestEnvironment()-        // Create empty V3 store (no readiness)-        let (result, _) = try await LibraryRepository.openV4ForApp(env.configuration)-        #expect(result == .setupRequired)--        let commitResult = try await LibraryRepository.confirmStartEmpty(env.configuration)--        guard case .committed(let counts) = commitResult else {-            Issue.record("Expected committed, got \(commitResult)")-            return-        }-        #expect(counts == .zero)-        // Readiness marker now exists-        #expect(FileManager.default.fileExists(atPath: env.configuration.v4MarkerURL.path))-    }--    @Test("confirmStartEmpty reacquires exclusive lock for its transition")-    func startEmptyReacquiresLock() async throws {-        let env = try TestEnvironment()-        _ = try await LibraryRepository.openV4ForApp(env.configuration)--        // After openV4ForApp, the lock is released. confirmStartEmpty should-        // reacquire it. If we hold the lock, it should timeout/fail.-        let held = try await CrossProcessLibraryLock.acquire(-            mode: .exclusive,-            at: env.configuration.lockURL,-            timeout: .seconds(1)-        )--        // With the lock held, confirmStartEmpty should throw libraryBusy-        await #expect(throws: LibraryRepositoryError.self) {-            try await LibraryRepository.confirmStartEmpty(env.configuration)-        }--        _ = held-    }--    @Test("confirmStartEmpty returns stale when library already has readiness")-    func startEmptyStaleWhenAlreadyReady() async throws {-        let env = try TestEnvironment()-        // Create a ready empty V3 store-        try createReadyEmptyV3Store(at: env.configuration)--        let result = try await LibraryRepository.confirmStartEmpty(env.configuration)--        guard case .stale = result else {-            Issue.record("Expected stale, got \(result)")-            return-        }-    }--    @Test("confirmStartEmpty returns stale when library is nonempty")-    func startEmptyStaleWhenNonempty() async throws {-        let env = try TestEnvironment()-        // Create nonempty V3 store without readiness-        try createPopulatedUnmarkedV3Store(at: env.configuration)--        let result = try await LibraryRepository.confirmStartEmpty(env.configuration)--        guard case .stale = result else {-            Issue.record("Expected stale, got \(result)")-            return-        }-    }--    @Test("confirmStartEmpty does not write when state is stale")-    func startEmptyZeroWriteOnStale() async throws {-        let env = try TestEnvironment()-        try createReadyEmptyV3Store(at: env.configuration)--        let markerBefore = try Data(contentsOf: env.configuration.v4MarkerURL)-        let result = try await LibraryRepository.confirmStartEmpty(env.configuration)--        guard case .stale = result else {-            Issue.record("Expected stale")-            return-        }-        // Marker unchanged-        let markerAfter = try Data(contentsOf: env.configuration.v4MarkerURL)-        #expect(markerBefore == markerAfter)+        #expect(after == .ready(.zero))     }      // MARK: - Fill Empty Import@@ -129,7 +45,6 @@ struct BackupImportTransactionTests {         let result = try await LibraryRepository.confirmImportFillEmpty(             env.configuration,             plan: plan,-            expectedState: .setupRequired,             saveStrategy: ModelContextSaveStrategy()         ) @@ -162,7 +77,6 @@ struct BackupImportTransactionTests {             try await LibraryRepository.confirmImportFillEmpty(                 env.configuration,                 plan: plan,-                expectedState: .setupRequired,                 saveStrategy: ModelContextSaveStrategy()             )         }@@ -173,14 +87,15 @@ struct BackupImportTransactionTests {     @Test("Fill-empty import returns stale when library is no longer empty")     func fillEmptyStaleWhenNonempty() async throws {         let env = try TestEnvironment()-        // Create a non-empty V3 store without readiness-        try createPopulatedUnmarkedV3Store(at: env.configuration)+        // Ready *and* populated: the marker guard must pass so this actually+        // reaches the emptiness check. An unmarked store would short-circuit+        // earlier and this would silently become the unmarked test below.+        try createReadyPopulatedV3Store(at: env.configuration)          let plan = try makeMinimalImportPlan()         let result = try await LibraryRepository.confirmImportFillEmpty(             env.configuration,             plan: plan,-            expectedState: .setupRequired,             saveStrategy: ModelContextSaveStrategy()         ) @@ -190,17 +105,19 @@ struct BackupImportTransactionTests {         }     } -    @Test("Fill-empty import returns stale when expectedState mismatches (readiness appeared)")-    func fillEmptyStateMismatch() async throws {+    @Test("Fill-empty import returns stale when the store is unmarked")+    func fillEmptyStaleWhenUnmarked() async throws {         let env = try TestEnvironment()-        try createReadyEmptyV3Store(at: env.configuration)+        // Import is a Settings action on a ready library. An unmarked store is+        // not one — the bootstrap marks at birth, so reaching this means the+        // state moved under the preview.+        _ = try await LibraryRepository.openV4ForApp(env.configuration)+        try FileManager.default.removeItem(at: env.configuration.v4MarkerURL)          let plan = try makeMinimalImportPlan()-        // Expects setupRequired but library is readyEmpty         let result = try await LibraryRepository.confirmImportFillEmpty(             env.configuration,             plan: plan,-            expectedState: .setupRequired,             saveStrategy: ModelContextSaveStrategy()         ) @@ -224,7 +141,6 @@ struct BackupImportTransactionTests {         _ = try await LibraryRepository.confirmImportFillEmpty(             env.configuration,             plan: plan,-            expectedState: .setupRequired,             saveStrategy: ModelContextSaveStrategy()         ) @@ -245,13 +161,13 @@ struct BackupImportTransactionTests {             try await LibraryRepository.confirmImportFillEmpty(                 env.configuration,                 plan: plan,-                expectedState: .setupRequired,                 saveStrategy: failingSaveStrategy             )         } -        // Library remains empty and unmarked (Req 1.17)-        #expect(!FileManager.default.fileExists(atPath: env.configuration.v4MarkerURL.path))+        // Library remains ready and empty (Req 1.17): the failed import wrote+        // nothing, and readiness was already published when the store was created.+        #expect(FileManager.default.fileExists(atPath: env.configuration.v4MarkerURL.path))         let schema = Schema(versionedSchema: AsterismSchemaV4.self)         let storeConfig = ModelConfiguration(             "AsterismV3",@@ -488,28 +404,7 @@ struct BackupImportTransactionTests {      // MARK: - Readiness Publication -    @Test("Fill-empty import from setupRequired publishes readiness marker")-    func fillEmptyPublishesReadiness() async throws {-        let env = try TestEnvironment()-        _ = try await LibraryRepository.openV4ForApp(env.configuration)-        #expect(!FileManager.default.fileExists(atPath: env.configuration.v4MarkerURL.path))--        let plan = try makeMinimalImportPlan()-        let result = try await LibraryRepository.confirmImportFillEmpty(-            env.configuration,-            plan: plan,-            expectedState: .setupRequired,-            saveStrategy: ModelContextSaveStrategy()-        )--        guard case .committed = result else {-            Issue.record("Expected committed")-            return-        }-        #expect(FileManager.default.fileExists(atPath: env.configuration.v4MarkerURL.path))-    }--    @Test("Fill-empty import from readyEmpty does not double-write readiness")+    @Test("Fill-empty import into a ready empty library retains readiness")     func fillEmptyFromReadyEmptyRetainsReadiness() async throws {         let env = try TestEnvironment()         try createReadyEmptyV3Store(at: env.configuration)@@ -518,7 +413,6 @@ struct BackupImportTransactionTests {         let result = try await LibraryRepository.confirmImportFillEmpty(             env.configuration,             plan: plan,-            expectedState: .readyEmpty,             saveStrategy: ModelContextSaveStrategy()         ) @@ -560,12 +454,12 @@ struct BackupImportTransactionTests {             _ = try await LibraryRepository.confirmImportFillEmpty(                 env.configuration,                 plan: plan,-                expectedState: .setupRequired,                 saveStrategy: ModelContextSaveStrategy()             )         }-        // A refused import publishes nothing.-        #expect(!FileManager.default.fileExists(atPath: env.configuration.v4MarkerURL.path))+        // A refused import materializes nothing.+        let (after, _) = try await LibraryRepository.openV4ForApp(env.configuration)+        #expect(after == .ready(.zero))     }      @Test(@@ -637,31 +531,6 @@ private func createReadyEmptyV3Store(at configuration: LibraryConfiguration) thr     try Data("4\n".utf8).write(to: configuration.v4MarkerURL, options: .atomic) } -private func createPopulatedUnmarkedV3Store(at configuration: LibraryConfiguration) throws {-    let fileManager = FileManager.default-    try fileManager.createDirectory(-        at: configuration.v4StoreURL.deletingLastPathComponent(),-        withIntermediateDirectories: true-    )-    let schema = Schema(versionedSchema: AsterismSchemaV4.self)-    let storeConfig = ModelConfiguration(-        "AsterismV3",-        schema: schema,-        url: configuration.v4StoreURL,-        cloudKitDatabase: .none-    )-    let container = try ModelContainer(-        for: schema,-        migrationPlan: AsterismV4MigrationPlan.self,-        configurations: [storeConfig]-    )-    let context = ModelContext(container)-    let site = Site(hostname: "example.com")-    context.insert(site)-    try context.save()-    // No marker-}- private func createReadyPopulatedV3Store(at configuration: LibraryConfiguration) throws {     let fileManager = FileManager.default     try fileManager.createDirectory(
Asterism/AsterismTests/IntegrationSafetyNetTests.swift Modified +18 / -33
diff --git a/Asterism/AsterismTests/IntegrationSafetyNetTests.swift b/Asterism/AsterismTests/IntegrationSafetyNetTests.swiftindex 1417227..90d3a1e 100644--- a/Asterism/AsterismTests/IntegrationSafetyNetTests.swift+++ b/Asterism/AsterismTests/IntegrationSafetyNetTests.swift@@ -224,23 +224,19 @@ struct IntegrationSafetyNetTests {         }     } -    // MARK: - M3 First-run setup, fill, and replace (Reqs 1.1, 1.4, 1.10, 1.11, 1.18)+    // MARK: - First launch, fill, and replace (Reqs 1.1, 1.4, 1.10, 1.11, 1.18) -    @Test("First-run setup blocks extension until Import or Start Empty completes")+    @Test("The extension stays closed until the app's first launch marks the library")     @MainActor-    func firstRunBlocksExtension() async throws {+    func extensionBlockedUntilFirstAppLaunch() async throws {         let fixture = try IntegrationFixture(publishReadiness: false)         defer { fixture.cleanup() }         let config = fixture.developmentConfiguration -        // App opens and sees setupRequired-        let (openResult, _) = try await LibraryRepository.openV4ForApp(config)-        #expect(openResult == .setupRequired)--        // Extension should throw because readiness is absent+        // Nothing on disk yet: the extension never creates or migrates a library.         do {             _ = try await LibraryRepository.openV4ForExtension(config)-            Issue.record("Extension should not open when readiness is absent")+            Issue.record("Extension should not open before the app has launched")         } catch let error as LibraryRepositoryError {             guard case .libraryUnavailable = error else {                 Issue.record("Expected libraryUnavailable, got \(error)")@@ -248,14 +244,16 @@ struct IntegrationSafetyNetTests {             }         } -        // Confirm Start Empty publishes readiness-        let commitResult = try await LibraryRepository.confirmStartEmpty(config)-        guard case .committed = commitResult else {-            Issue.record("Expected committed for Start Empty, got \(commitResult)")-            return-        }+        // The app's first launch creates the store and marks it ready at birth+        // (T-1969) — there is no setup step in between.+        let (openResult, _) = try await LibraryRepository.openV4ForApp(config)+        #expect(openResult == .ready(.zero)) -        // Now extension can open+        // The extension can capture from here on. This is the guard T-1969+        // deliberately gives up: capture into a fresh library is now possible+        // before a reader restores a backup through Settings. A replace-style+        // import would discard that capture, but only behind the two-step+        // destructive confirmation, which shows the current counts first.         let (extResult, extRepo) = try await LibraryRepository.openV4ForExtension(config)         #expect(extResult == .ready(.zero))         let captured = try await extRepo.capture(@@ -301,16 +299,15 @@ struct IntegrationSafetyNetTests {         // Build import plan from exported data         let plan = try BackupImporter.planV4(from: backupData) -        // Create a fresh empty unmarked target library+        // Create a fresh, ready, empty target library         let targetConfig = fixture.personalConfiguration         try? FileManager.default.removeItem(at: targetConfig.v4MarkerURL)         let (targetOpenResult, _) = try await LibraryRepository.openV4ForApp(targetConfig)-        #expect(targetOpenResult == .setupRequired)+        #expect(targetOpenResult == .ready(.zero))          let fillResult = try await LibraryRepository.confirmImportFillEmpty(             targetConfig,-            plan: plan,-            expectedState: .setupRequired+            plan: plan         )         guard case .committed(let counts) = fillResult else {             Issue.record("Expected committed fill, got \(fillResult)")@@ -1090,22 +1087,10 @@ private func openV3AppRepository(     _ configuration: LibraryConfiguration,     capabilities: AsterismCapabilities = .current ) async throws -> LibraryRepository {-    var (result, repository) = try await LibraryRepository.openV4ForApp(+    let (_, repository) = try await LibraryRepository.openV4ForApp(         configuration,         capabilities: capabilities     )-    if case .setupRequired = result {-        // Fresh isolated config: confirm start-empty and reopen so the helper-        // always yields a ready repository (mirrors first-run setup).-        _ = try await LibraryRepository.confirmStartEmpty(configuration, capabilities: capabilities)-        (result, repository) = try await LibraryRepository.openV4ForApp(configuration, capabilities: capabilities)-    }-    guard let repository else {-        throw LibraryRepositoryError.libraryUnavailable(-            operation: "opening V4 test repository",-            reason: "openV4ForApp returned \(result) without a repository"-        )-    }     return repository } 
Asterism/AsterismTests/AppLibraryModelTests.swift Modified +12 / -9
diff --git a/Asterism/AsterismTests/AppLibraryModelTests.swift b/Asterism/AsterismTests/AppLibraryModelTests.swiftindex 1ede10b..4e4c9e8 100644--- a/Asterism/AsterismTests/AppLibraryModelTests.swift+++ b/Asterism/AsterismTests/AppLibraryModelTests.swift@@ -20,9 +20,10 @@ struct AppLibraryModelTests {         #expect(model.worksSnapshot.works.isEmpty)     } -    @Test("Transitions to setupRequired when path has no V3 readiness")-    @MainActor func bootstrapNoV3Readiness() async {-        // No V3 store or marker → first-run setup required.+    @Test("A first run with no store opens an empty library, with no setup step")+    @MainActor func bootstrapFirstRunOpensEmpty() async {+        // No store or marker: the bootstrap creates the store, marks it ready,+        // and the reader lands straight in an empty library (T-1919, T-1969).         let root = FileManager.default.temporaryDirectory             .appending(path: "asterism-setup-\(UUID())")         let config = LibraryConfiguration(@@ -31,8 +32,10 @@ struct AppLibraryModelTests {         )         let model = AppLibraryModel(configuration: config)         await model.bootstrap()-        #expect(model.state == .setupRequired)-        #expect(model.setupModel != nil)+        #expect(model.state == .ready)+        #expect(model.recentGroups.isEmpty)+        #expect(model.worksSnapshot.works.isEmpty)+        #expect(FileManager.default.fileExists(atPath: config.v4MarkerURL.path))         try? FileManager.default.removeItem(at: root)     } @@ -47,7 +50,7 @@ struct AppLibraryModelTests {         try? FileManager.default.removeItem(at: tmp)     } -    @Test("Retry after setupRequired re-attempts bootstrap")+    @Test("Retry re-attempts the bootstrap and stays ready")     @MainActor func retryTransition() async {         let root = FileManager.default.temporaryDirectory             .appending(path: "asterism-retry-\(UUID())")@@ -57,10 +60,10 @@ struct AppLibraryModelTests {         )         let model = AppLibraryModel(configuration: config)         await model.bootstrap()-        #expect(model.state == .setupRequired)-        // Retry re-attempts (still no V3 marker).+        #expect(model.state == .ready)+        // Retry reopens the now-marked store rather than recreating it.         await model.retry()-        #expect(model.state == .setupRequired)+        #expect(model.state == .ready)         try? FileManager.default.removeItem(at: root)     } 
Asterism/AsterismTests/SettingsImportTests.swift Renamed +316 / -0 (was FirstRunAndSettingsImportTests.swift)
diff --git a/Asterism/AsterismTests/SettingsImportTests.swift b/Asterism/AsterismTests/SettingsImportTests.swiftnew file mode 100644index 0000000..139b897--- /dev/null+++ b/Asterism/AsterismTests/SettingsImportTests.swift@@ -0,0 +1,316 @@+import AsterismCore+import Foundation+import Testing+@testable import Asterism++// MARK: - Mock Document Reader++final class MockBackupDocumentReader: BackupDocumentReading, @unchecked Sendable {+    var readDataCallCount = 0+    var lastReadURL: URL?+    var readDataResult: Result<Data, Error> = .failure(MockSetupError.notConfigured)++    func readData(from url: URL) throws -> Data {+        readDataCallCount += 1+        lastReadURL = url+        return try readDataResult.get()+    }+}++// MARK: - Mock Import Committer++final class MockBackupImportCommitter: BackupImportCommitting, @unchecked Sendable {+    var confirmFillEmptyCallCount = 0+    var confirmReplaceCallCount = 0+    var computeFingerprintCallCount = 0++    var lastFillPlan: BackupImportV4Plan?+    var lastReplacePlan: BackupImportV4Plan?+    var lastReplaceInventory: LibraryInventoryFingerprint?++    var confirmFillEmptyResult: Result<BackupImportCommitResult, Error> = .failure(MockSetupError.notConfigured)+    var confirmReplaceResult: Result<BackupImportCommitResult, Error> = .failure(MockSetupError.notConfigured)+    var computeFingerprintResult: Result<LibraryInventoryFingerprint, Error> = .failure(MockSetupError.notConfigured)++    func confirmImportFillEmpty(+        _ configuration: LibraryConfiguration,+        plan: BackupImportV4Plan,+        capabilities: AsterismCapabilities+    ) async throws -> BackupImportCommitResult {+        confirmFillEmptyCallCount += 1+        lastFillPlan = plan+        return try confirmFillEmptyResult.get()+    }++    func confirmImportReplace(+        _ configuration: LibraryConfiguration,+        plan: BackupImportV4Plan,+        expectedInventory: LibraryInventoryFingerprint,+        capabilities: AsterismCapabilities+    ) async throws -> BackupImportCommitResult {+        confirmReplaceCallCount += 1+        lastReplacePlan = plan+        lastReplaceInventory = expectedInventory+        return try confirmReplaceResult.get()+    }++    func computeInventoryFingerprint(+        configuration: LibraryConfiguration+    ) async throws -> LibraryInventoryFingerprint {+        computeFingerprintCallCount += 1+        return try computeFingerprintResult.get()+    }+}++enum MockSetupError: Error, LocalizedError {+    case notConfigured+    case simulatedFailure(String)++    var errorDescription: String? {+        switch self {+        case .notConfigured: "Mock not configured"+        case .simulatedFailure(let msg): msg+        }+    }+}++// MARK: - Minimal valid backup data for planning tests++/// Creates minimal valid V3 backup JSON data that passes BackupImporter.plan().+private func makeMinimalV3BackupData(+    entries: Int = 1,+    works: Int = 1+) -> Data {+    // We need real valid data that BackupImporter.plan can process.+    // For now, use a stub approach: the committer mock is what matters for model tests.+    // The actual parsing is tested at the Core layer. Here we test the model state machine.+    Data()+}++// MARK: - SettingsBackupImportModel Tests++@Suite("SettingsBackupImportModel")+struct SettingsBackupImportModelTests {++    private func makeConfiguration() -> LibraryConfiguration {+        LibraryConfiguration(+            rootDirectory: URL(filePath: "/tmp/test-settings-import-\(UUID())"),+            environment: .development+        )+    }++    // MARK: - Initial State++    @Test("Starts in idle state")+    @MainActor func initialState() {+        let model = SettingsBackupImportModel(+            configuration: makeConfiguration(),+            documentReader: MockBackupDocumentReader(),+            committer: MockBackupImportCommitter(),+            onCompletion: {}+        )+        #expect(model.state == .idle)+    }++    // MARK: - Document Picker++    @Test("Begin import transitions to pickingDocument")+    @MainActor func beginImport() {+        let model = SettingsBackupImportModel(+            configuration: makeConfiguration(),+            documentReader: MockBackupDocumentReader(),+            committer: MockBackupImportCommitter(),+            onCompletion: {}+        )+        model.beginImport()+        #expect(model.state == .pickingDocument)+    }++    @Test("Picker cancellation returns to idle with zero writes")+    @MainActor func pickerCancellation() {+        let committer = MockBackupImportCommitter()+        let model = SettingsBackupImportModel(+            configuration: makeConfiguration(),+            documentReader: MockBackupDocumentReader(),+            committer: committer,+            onCompletion: {}+        )+        model.beginImport()+        model.handlePickerCancellation()++        #expect(model.state == .idle)+        #expect(committer.confirmFillEmptyCallCount == 0)+        #expect(committer.confirmReplaceCallCount == 0)+    }++    // MARK: - Document Selection: Validation Errors++    @Test("Document selection with security scope denial transitions to failed")+    @MainActor func documentSecurityScopeDenied() async {+        let reader = MockBackupDocumentReader()+        reader.readDataResult = .failure(BackupDocumentError.securityScopeAccessDenied(url: URL(filePath: "/f")))++        let model = SettingsBackupImportModel(+            configuration: makeConfiguration(),+            documentReader: reader,+            committer: MockBackupImportCommitter(),+            onCompletion: {}+        )+        model.beginImport()+        await model.handleDocumentSelection(URL(filePath: "/f"))++        if case .failed(let message) = model.state {+            #expect(message.contains("access") || message.contains("Cannot"))+        } else {+            Issue.record("Expected failed state, got \(model.state)")+        }+    }++    @Test("Document selection with invalid format transitions to failed")+    @MainActor func documentInvalidFormat() async {+        let reader = MockBackupDocumentReader()+        reader.readDataResult = .success(Data("garbage".utf8))++        let model = SettingsBackupImportModel(+            configuration: makeConfiguration(),+            documentReader: reader,+            committer: MockBackupImportCommitter(),+            onCompletion: {}+        )+        model.beginImport()+        await model.handleDocumentSelection(URL(filePath: "/f"))++        if case .failed = model.state {+            // Expected+        } else {+            Issue.record("Expected failed state, got \(model.state)")+        }+    }++    // MARK: - Destructive Replacement Flow++    @Test("Proceed to replace confirmation transitions correctly")+    @MainActor func proceedToReplaceConfirmation() {+        let _ = SettingsBackupImportModel.ReplacePreview(+            metadata: BackupImportMetadata(+                formatVersion: 3, schemaVersion: 3, appBuild: "1",+                exportedAt: Date(), capabilityGate: "m3", entryCount: 5, workCount: 2+            ),+            importCounts: LibraryRecordCounts(entries: 5, works: 2, sites: 1, titlePatterns: 0),+            currentCounts: LibraryRecordCounts(entries: 10, works: 3, sites: 2, titlePatterns: 1),+            inventory: LibraryInventoryFingerprint(+                counts: LibraryRecordCounts(entries: 10, works: 3, sites: 2, titlePatterns: 1),+                entitySignature: "test-sig"+            )+        )++        let committer = MockBackupImportCommitter()+        let model = SettingsBackupImportModel(+            configuration: makeConfiguration(),+            documentReader: MockBackupDocumentReader(),+            committer: committer,+            onCompletion: {}+        )++        // Manually set state to readyToReplace for unit testing the state machine+        // In production this happens after handleDocumentSelection+        // We test the state machine transitions here+        // Note: We can't directly set state, so we test the flow through the committer++        // Test cancel from idle+        model.cancel()+        #expect(model.state == .idle)+    }++    // MARK: - Cancel and Retry++    @Test("Cancel returns to idle")+    @MainActor func cancelReturnsToIdle() {+        let model = SettingsBackupImportModel(+            configuration: makeConfiguration(),+            documentReader: MockBackupDocumentReader(),+            committer: MockBackupImportCommitter(),+            onCompletion: {}+        )+        model.beginImport()+        model.cancel()+        #expect(model.state == .idle)+    }++    @Test("Retry returns to idle after failure")+    @MainActor func retryReturnsToIdle() async {+        let reader = MockBackupDocumentReader()+        reader.readDataResult = .failure(BackupDocumentError.emptyFile(url: URL(filePath: "/f")))++        let model = SettingsBackupImportModel(+            configuration: makeConfiguration(),+            documentReader: reader,+            committer: MockBackupImportCommitter(),+            onCompletion: {}+        )+        model.beginImport()+        await model.handleDocumentSelection(URL(filePath: "/f"))++        guard case .failed = model.state else {+            Issue.record("Expected failed state first"); return+        }++        model.retry()+        #expect(model.state == .idle)+    }++    // MARK: - Zero Writes on Failure++    @Test("No Core commit calls made when document read fails")+    @MainActor func noCommitCallsOnReadFailure() async {+        let reader = MockBackupDocumentReader()+        reader.readDataResult = .failure(BackupDocumentError.emptyFile(url: URL(filePath: "/f")))++        let committer = MockBackupImportCommitter()+        let model = SettingsBackupImportModel(+            configuration: makeConfiguration(),+            documentReader: reader,+            committer: committer,+            onCompletion: {}+        )+        model.beginImport()+        await model.handleDocumentSelection(URL(filePath: "/f"))++        #expect(committer.confirmFillEmptyCallCount == 0)+        #expect(committer.confirmReplaceCallCount == 0)+    }++    @Test("No Core commit calls made when planning fails")+    @MainActor func noCommitCallsOnPlanningFailure() async {+        let reader = MockBackupDocumentReader()+        reader.readDataResult = .success(Data("{}".utf8))++        let committer = MockBackupImportCommitter()+        let model = SettingsBackupImportModel(+            configuration: makeConfiguration(),+            documentReader: reader,+            committer: committer,+            onCompletion: {}+        )+        model.beginImport()+        await model.handleDocumentSelection(URL(filePath: "/f"))++        #expect(committer.confirmFillEmptyCallCount == 0)+        #expect(committer.confirmReplaceCallCount == 0)+    }++    // MARK: - Accessibility++    @Test("State equality for UI binding")+    @MainActor func stateEquality() {+        let idle: SettingsBackupImportModel.State = .idle+        let picking: SettingsBackupImportModel.State = .pickingDocument+        let committing: SettingsBackupImportModel.State = .committing+        let failed: SettingsBackupImportModel.State = .failed(message: "x")+        let failed2: SettingsBackupImportModel.State = .failed(message: "y")++        #expect(idle != picking)+        #expect(picking != committing)+        #expect(failed != failed2)+    }+}
specs/unified-teaching-composition/decision_log.md Modified +118 / -0
diff --git a/specs/unified-teaching-composition/decision_log.md b/specs/unified-teaching-composition/decision_log.mdindex 0809ab2..1349de2 100644--- a/specs/unified-teaching-composition/decision_log.md+++ b/specs/unified-teaching-composition/decision_log.md@@ -332,3 +332,121 @@ Keeping segment granularity as the default is deliberate. `.segment` matches pos `ComposedTeachingView` (mode picker, kept-span selector, phrase editor, boundary controls), `ComposedTeachingViewModel` (`TitleMode`, `selectTitleMode`, kept-span state, phrase state, and the `buildTitleDefinition` inference), and the composed-surface UI tests. `AsterismCore` is unaffected: no rule form, validator, gate, or wire format changes.  ---++## Decision 9: Publish the readiness marker when the empty store is created++**Date**: 2026-07-27+**Status**: accepted++### Context++`openV4ForApp` created the empty store on a fresh install and returned+`.setupRequired` *without* publishing the `AsterismV4.ready` marker. The marker+waited for the reader to answer a first-run choice — "import a V2 backup" or+"confirm start empty" — that Q25's bootstrap state table recorded as "existing+first-run setup".++That left a window in which a store existed on disk carrying no marker. Nothing+holds a lease across it: the app releases the bootstrap lease before rendering+UI, and the reader may take minutes to answer or never answer at all. Any writer+that fills the store during the window turns it into the nonempty-unmarked state+— which Q25 deliberately made a loud, unrecoverable failure. Every subsequent+launch then throws, and the share extension fails closed, with no in-app route+back. Deleting the app is the only recovery. CloudKit mirroring, planned in+`specs/cloudkit-mirroring/`, fills a store unprompted and would make this+reachable in ordinary use; it is what surfaced the defect (T-1969).++A second failure needed no crash at all: `confirmStartEmpty` re-read the store+and required `counts == .zero`, so once a single record arrived the only+remaining path out of the setup screen returned `.stale` forever.++Separately, T-1919 recorded the choice itself as not worth its cost: it asks the+reader to decide something before there is anything to decide about, and+Settings → Import already offers the same capability against a real library.++### Decision++Publish the readiness marker at the moment the empty store is created, and+delete the first-run choice. `openV4ForApp` now returns an open, ready library+or throws — there is no third state for a reader to resolve. An empty *unmarked*+store found on a later launch is likewise marked and opened.++Q25's nonempty-unmarked throw is retained unchanged. A V3 marker whose store has+gone now fails closed rather than silently starting an empty library.++### Rationale++The marker certifies migration completeness and store-level validity (Req 5.4).+A store this process just created has no migration to complete and no content to+validate, so there is nothing the deferred confirmation was establishing — the+delay bought no safety and cost a permanently unopenable library.++Q25 is narrowed, not overturned. Its subject is the *nonempty* unmarked store,+where "did a migration die halfway?" is a real and unanswerable question. For an+empty store the question has an answer: `v3Counts` covers `Entry`, `Work`,+`Site`, `TitlePattern` and `URLRulePattern`, which is exactly+`AsterismSchemaV4.models`, so `counts == .zero` means no row of any kind exists.+Emptiness is measured rather than assumed from "we just created it", so a store+that unexpectedly comes up nonempty falls through to Q25's throw instead of+being certified. Ordering keeps the invariant across crashes: the store file+exists before the marker is written, so a crash in between leaves an empty+unmarked store, which the next launch marks.++### Alternatives Considered++- **Keep the choice, mark eagerly anyway**: closes the window while retaining the+  screen — Rejected. The screen's only remaining function would be a backup+  import that Settings already performs against a real library, so it would ask+  the reader to answer a question whose outcomes had become identical.+- **Mark on first write instead of at creation**: narrows the window rather than+  closing it — Rejected. It moves the failure to whichever writer arrives first+  and leaves the extension failing closed for an unbounded period; the extension+  cannot mark, because it must never migrate (Req 5.4).+- **Treat any empty unmarked store as ready without measuring**: simpler, one+  branch — Rejected. It reintroduces exactly the assumption Q25 exists to+  forbid, and would certify content whose provenance was never checked.+- **Fail closed on empty unmarked, forcing a reinstall**: maximally+  conservative — Rejected. It converts a benign, self-healing state into data+  loss, and would brick every library created by a build that predates this+  decision.++### Consequences++**Positive:**+- The permanently-unopenable state is unreachable: no window exists in which a+  store lacks a marker while a writer can reach it.+- CloudKit mirroring is unblocked; it was the ticket's stated blocker.+- A fresh install opens directly into an empty library.+- First launch performs one store open instead of three (create → confirm →+  reopen), and one lease acquisition instead of three.+- An empty unmarked store left by any earlier build self-heals on next launch.++**Negative:**+- **A guard is given up, not merely deferred.** The extension used to fail closed+  until setup completed, which incidentally prevented capturing into a library+  the reader was about to replace with a backup. It can now capture from first+  launch, and a replace-style import would discard that capture. Accepted: the+  guard was incidental to a screen being removed, the alternative is a+  permanently unopenable library, and the replace path still displays current+  record counts behind a two-step destructive confirmation, so the capture is+  confirmed away rather than silently lost.+- Six requirements in `specs/url-identity-re-share/` are superseded — 1.1, 1.3+  (first-run half), 1.4, 1.5, 1.18 (first clause), 1.19 (setup-rendering+  clause). Those documents are left as the M3 record; `specs/OVERVIEW.md`+  carries the carry-forward note.+- Q31 in `specs/library-integrity-tolerance/` names `confirmStartEmpty`, which+  this decision deletes; the row is annotated rather than removed.+- `V4OpeningResult` is reduced to a single case and `SetupOrReadyEmptyState`+  removed, changing Core's public surface.++### Impact++`LibraryRepository+V4Bootstrap.swift` (both empty-store branches, the new+V3-marker-without-store guard), `LibraryRepository+BackupImport.swift`+(`confirmStartEmpty` deleted, `expectedState` parameter removed),+`BackupImporter.swift` (`SetupOrReadyEmptyState`, `BackupImportCommitMode`+removed), and the app's `AppLibraryModel` / `ContentView` /+`FirstRunLibrarySetupModel` / `FirstRunLibrarySetupView`. Settings → Import is+unchanged.++---
specs/unified-teaching-composition/design.md Modified +5 / -3
diff --git a/specs/unified-teaching-composition/design.md b/specs/unified-teaching-composition/design.mdindex 1523322..ca0b8b9 100644--- a/specs/unified-teaching-composition/design.md+++ b/specs/unified-teaching-composition/design.md@@ -24,12 +24,14 @@ SwiftData custom stages see only old models in `willMigrate` and only new models | V3 marker, V3 store | run migration steps 1–3 | | V3 store, sidecar present | crash before conversion: rewrite sidecar, re-run | | V4 store, sidecar present, no V4 marker | crash mid-completion: resume completion pass |-| V4 store, no sidecar, no V4 marker | unverifiable partial migration → `libraryUnavailable` (explicit restore-from-backup path), never silent certification |+| V4 store *nonempty*, no sidecar, no V4 marker | unverifiable partial migration → `libraryUnavailable` (explicit restore-from-backup path), never silent certification |+| V4 store *empty*, no sidecar, no V4 marker | publish the marker and open — nothing to verify (amended by Decision 9, T-1969; was "existing first-run setup") | | corrupt sidecar (checksum fails) next to V4 store | same as above — fail loudly, never guess | | both markers | V4 governs; delete stale V3 marker |-| neither marker, no store | existing first-run setup |+| V3 marker, no store | certified library whose file is gone → `libraryUnavailable`, marker preserved (Decision 9) |+| neither marker, no store | create the store and publish the marker (amended by Decision 9, T-1969; was "existing first-run setup") | -M3's "valid nonempty unmarked ⟹ ready" recovery heuristic is not carried into V4.+M3's "valid nonempty unmarked ⟹ ready" recovery heuristic is not carried into V4. Decision 9 narrows this to what it always said — *nonempty* — and marks the empty case, which carries no history to be wrong about.  **What the marker certifies (approved, Req 5.4):** migration completeness and store-level validity (schema converted, no cross-Site duplicates or unresolved references). It does **not** promise zero per-Site diagnoses: a migrated Site that validates illegal is quarantined like any other (Req 9.4), the marker is still written, and the extension operates with conservative capture on that Site. This is the only reading under which Req 5.4 and Req 9.4 don't contradict each other. 
specs/OVERVIEW.md Modified +4 / -0
diff --git a/specs/OVERVIEW.md b/specs/OVERVIEW.mdindex 7296ce0..b282889 100644--- a/specs/OVERVIEW.md+++ b/specs/OVERVIEW.md@@ -35,6 +35,10 @@ Adds reader-taught title parsing, retroactive organization, provenance, and acti  Adds exact URL-derived identity, safe re-share editing, conflict recovery, confirmed Work URLs, Work Merge, and explicit V2/V3 backup handoff. +**Carried forward — do not let these disappear into the decision log:**++- **The first-run setup flow this spec designed no longer exists** (T-1919, T-1969, 2026-07-27). Readiness is published when the empty store is created, so a fresh install opens straight into an empty library. **Reqs 1.1, 1.4, 1.5, 1.19 and the first-run half of 1.3 are superseded**, as is the "publish readiness when it was absent" clause of 1.18 — import now always commits into an already-ready library. `FirstRunLibrarySetupModel`, `confirmStartEmpty`, `BackupImportCommitMode` and `SetupOrReadyEmptyState` are deleted. **Settings → Import (fill and destructive replace) is unchanged and still the supported path.** The requirements, design and tasks here are left as the M3 record; the reasoning, the alternatives, and the one guard deliberately given up are in Decision 9 of [unified-teaching-composition](unified-teaching-composition/decision_log.md), which owns the bootstrap state table.+ - [decision_log.md](url-identity-re-share/decision_log.md) - [design.md](url-identity-re-share/design.md) - [implementation.md](url-identity-re-share/implementation.md)
specs/library-integrity-tolerance/decision_log.md Modified +1 / -1
diff --git a/specs/library-integrity-tolerance/decision_log.md b/specs/library-integrity-tolerance/decision_log.mdindex 10d9187..4c48516 100644--- a/specs/library-integrity-tolerance/decision_log.md+++ b/specs/library-integrity-tolerance/decision_log.md@@ -34,7 +34,7 @@ | Q28 | 2026-07-26 | `validateStrict` is one routine with a `Strictness` mode, not a verbatim copy of the old implementation | The design said "the current implementation, unchanged". Two copies of the orchestration would drift. Behaviour is preserved — grouping is an ordered pass, so strict throws on the same element with the same payload as the `unique()` helper it replaces — but a reviewer expecting a literal copy will not find one | | Q29 | 2026-07-26 | Tuple validation under duplicates runs against the `SiteResolutionOrder` winner, and losing duplicate records are not tuple-validated | Matches what the read paths and capture resolve to. Validating losers against an index holding only winners would manufacture `.siteTuple` diagnoses, and therefore quarantine, out of a state Q12 says must not quarantine. Every Site *row* is still tuple-validated individually, so a second row's own illegality is not hidden | | Q30 | 2026-07-26 | The re-share duplicate-UUID fix also collapses the `currentMatches` set by application UUID before the ambiguity check | Resolving the winner alone was not sufficient: `+Capture.swift` re-derives the match set about twenty lines later and two rows sharing a UUID also share their identity key, so `currentMatches.count != 1` returned `.stale` instead of succeeding. "One Entry materialised twice" is not the ambiguity that guard exists for. Not mentioned in the task text; found by testing Req 1.2 end to end |-| Q31 | 2026-07-26 | `confirmStartEmpty` (`+BackupImport.swift:73`) stays on the tolerant validator | The spec names three import gates and there are four `validateV4Store` call sites. This fourth one is preceded two lines above by a `counts == .zero` check, so tolerant and strict are provably equivalent there and moving it would imply a distinction that does not exist |+| Q31 | 2026-07-26 | ~~`confirmStartEmpty` (`+BackupImport.swift:73`) stays on the tolerant validator~~ — superseded 2026-07-27 by T-1969: `confirmStartEmpty` is deleted, and the mark-at-birth branches that replace it validate nothing at all, for this row's own `counts == .zero` reason. Three `validateV4Store` call sites remain, all import gates | The spec names three import gates and there are four `validateV4Store` call sites. This fourth one is preceded two lines above by a `counts == .zero` check, so tolerant and strict are provably equivalent there and moving it would imply a distinction that does not exist | | Q32 | 2026-07-26 | A capture into a `.duplicateSiteRows` hostname applies **no** rules, not the winner's | `.duplicateSiteRows` quarantines (Q12), and a quarantined Site already takes the conservative no-rule path at `+ReparseCapture.swift:297` and `:411` (Req 9.4, predating this spec). So Decision 9's winner-only half is inert for that state on the commit path — the winner is used by `captureLookup`, which is not quarantine-gated, but the saved Entry gets no rules applied. This is the right behaviour (untrusted teaching is not applied) but it makes Req 5.4's "capture rule application in every state from 1.1" partly vacuous for the duplicate-Site state, and task 34 ("Write the scale tests for the tolerated states") should measure it knowing that rather than reporting a fast number for work that is not happening | | Q33 | 2026-07-26 | Cited ownership is tested as hostname equality (`rule.site?.hostname == hostname`), not membership in a fetched row array | O(1) with no extra fetch and no relationship walk, and it expresses exactly "any row for this hostname owns it". Keeps the union entirely off the cost path, which matters because the extension open path has ~220 ms of headroom (Decision 10) | | Q34 | 2026-07-26 | The `=== site` tests inside `validate(site:)` (`:280`, `:284`, `:291`, `:309`) are deliberately NOT widened to the union | Those ask whether a row's own tuple is internally consistent. Widening them to the hostname would report every duplicated hostname's membership set as incomplete, manufacturing the diagnoses Q29 exists to avoid. This was the one genuinely ambiguous classification in the phase; commented in place |
CHANGELOG.md Modified +3 / -2
diff --git a/CHANGELOG.md b/CHANGELOG.mdindex 4c3b556..2d85c9f 100644--- a/CHANGELOG.md+++ b/CHANGELOG.md@@ -24,6 +24,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).  ### Fixed +- Fixed a new library becoming permanently unopenable, and removed the first-run setup screen that caused it. A fresh install used to create its library and then withhold the marker that certifies it until you answered "import a backup or start empty" — a question with nothing behind it, since there was no library to import into yet. Anything writing in that gap left a library the app could no longer classify, and every later launch refused to open it with no way back short of deleting the app. The library is now marked the moment it is created, so a fresh install opens straight into an empty library and the question is gone. One consequence worth knowing: the share extension now works from first launch instead of waiting for that answer, so a page shared before you restore a backup will be in the library when you do — restoring still shows you what it is about to discard and still asks twice. Importing a backup is unchanged and still lives in Settings. A library that is nonempty but uncertified is still refused rather than guessed at, which is the case that check was written for; a certified library whose store file has gone now says so instead of quietly starting you over on an empty one. - Fixed a diagnosed site being impossible to re-teach. Re-teaching a site whose stored rules were already flagged rolled the commit back every time, even when the new teaching was perfectly good — so the one action offered to repair the site could never be taken. A re-teach now commits unless it would introduce a *different* problem than the one already there, and says what that would be when it refuses. Repairing the site clears the flag; leaving it unchanged commits without pretending it was repaired. - Fixed backup export failing with an internal decoding error on a library carrying unresolvable records. A site row missing for a host, or two records sharing an identifier, do not stop the library working — but they did stop a backup, and the failure surfaced as `decode-validation failed: …` from inside the archive writer rather than as anything a reader could act on. Export now declines up front and states how many records could not be resolved. Nothing is written. A coherent library exports exactly as before; the archive format is unchanged. - Fixed Teach being offered on sites where teaching is refused. A site with duplicate rows no longer shows a Teach action in Recent or on an entry's detail screen, and its entries no longer count toward the "needs teaching" total — tapping that total previously filtered to a list of rows carrying no action. The rows still appear and are still marked as needing attention; the diagnostics screen is the route for them. A site whose stored rules are merely inconsistent is unaffected and still offers Teach, because re-teaching is exactly what repairs it.@@ -92,9 +93,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - Added pure same-Site Work Merge projection with target-wins metadata, exact-scalar tag union, source URL promotion, complete before/after identity evidence, and canonical append-only audit blocks that retain discarded curation.  - Added the Schema V3 foundation and generalized `AsterismCapabilities` gate, with closed URL-rule, Entry identity/sequence, Work identity, assignment-provenance, and imported-history tuples validated before runtime use or backup restore.-- Added fixed-path V3 app and extension opening behind bounded cross-process leases: the app creates or validates the current store and withholds readiness until explicit setup, while the extension opens only a ready validated V3 library and reports retryable contention.+- Added fixed-path V3 app and extension opening behind bounded cross-process leases: the app creates or validates the current store and marks it ready, while the extension opens only a ready validated V3 library and reports retryable contention. - Added strict Backup V3 export plus import-only frozen Backup V2 decoding and V2-to-V3 mapping, with checksum/reference validation, test-only legacy fixture provenance, atomic empty fill or destructive replacement, stale inventory rejection, rollback, and readiness publication.-- Added first-run Import Backup / confirmed Start Empty setup and Settings backup fill/replacement flows, including security-scoped document reads, validated previews, separate destructive confirmation, accessibility labels, retry handling, and immediate V3 repository reopening after commit.+- Added Settings backup fill/replacement flows, including security-scoped document reads, validated previews, separate destructive confirmation, accessibility labels, retry handling, and immediate V3 repository reopening after commit.  - Added the approved M3 URL Identity & Re-Share specification and Planned roadmap entry, defining exact URL-derived identity, safe re-share editing, conservative conflict recovery, confirmed Work URLs, atomic Work Merge, and explicit V2/V3 backup fill or destructive replacement; included a 60-task/30-pair red-green implementation plan and an immutable pre-M3 `2/2/m2.3` Backup V2 compatibility fixture. 
docs/agent-notes/schema-migration.md Modified +8 / -0
diff --git a/docs/agent-notes/schema-migration.md b/docs/agent-notes/schema-migration.mdindex c747deb..d1e7124 100644--- a/docs/agent-notes/schema-migration.md+++ b/docs/agent-notes/schema-migration.md@@ -34,6 +34,14 @@ longer exist.   `conservativeIdentityKey`) → `V4LibraryValidator` → publish `AsterismV4.ready`   (`"4"`) → delete the V3 marker and the sidecar. See   `LibraryRepository+V4Bootstrap.swift` and `V4Migration.swift`.+- **An empty store is marked ready at birth** (T-1969, Decision 9 in+  `specs/unified-teaching-composition/`). `openV4ForApp` returns an open ready+  library or throws — there is no `.setupRequired` and no first-run screen. Both+  the "no store" and "store, no markers, no sidecar" branches converge on one+  check: publish the marker iff `v3Counts == .zero`, measured *after* creation+  rather than assumed. A nonempty unmarked store still hits the+  unverifiable-partial-migration throw (Q25), and a V3 marker whose store is+  gone now fails closed instead of starting an empty library over it. - **Deleted:** `V3LibraryValidator` (→ `V4LibraryValidator`),   `SiteTitleInterpretation`, `WorkOnlyTitleCleaner`, `openV3ForApp` /   `openV3ForExtension`, and the M3 URL-teaching commit subsystem
docs/agent-notes/composed-teaching-ui.md Modified +4 / -4
diff --git a/docs/agent-notes/composed-teaching-ui.md b/docs/agent-notes/composed-teaching-ui.mdindex 6075023..465f6f6 100644--- a/docs/agent-notes/composed-teaching-ui.md+++ b/docs/agent-notes/composed-teaching-ui.md@@ -13,10 +13,10 @@ successfully in production.  The `.composed` UI-test fixture (`seeded-composed` scenario) no longer needs a special bootstrap. `AppLibraryModel.bootstrap()` now opens `openV4ForApp` for all-launches; the `.composed` fixture goes through the ordinary V4 setup path-(`openV4ForApp` → `confirmStartEmpty` → reopen) and then `seedComposedFixture`-seeds an untaught actionable Entry on `composed.test` plus a composed-taught Site-`id.test` with URL identity. `openV4Container`, `makeRepository`, and+launches, and since T-1969 that single call creates the store, marks it ready and+returns an open repository — no confirm-and-reopen dance. `seedComposedFixture`+then seeds an untaught actionable Entry on `composed.test` plus a composed-taught+Site `id.test` with URL identity. `openV4Container`, `makeRepository`, and `publishV4Readiness` remain `public` (still used by tests).  `ComposedSurfaceUITests` passes on the simulator (`testEntryDetailTeachOpensComposedSurface`,
Packages/AsterismCore/Sources/AsterismStoreTestHelper/main.swift Modified +3 / -6
diff --git a/Packages/AsterismCore/Sources/AsterismStoreTestHelper/main.swift b/Packages/AsterismCore/Sources/AsterismStoreTestHelper/main.swiftindex fa6217c..fa01f42 100644--- a/Packages/AsterismCore/Sources/AsterismStoreTestHelper/main.swift+++ b/Packages/AsterismCore/Sources/AsterismStoreTestHelper/main.swift@@ -67,15 +67,12 @@ enum AsterismStoreTestHelper {             rootDirectory: URL(filePath: arguments[0], directoryHint: .isDirectory),             environment: environment         )-        let (result, repository) = try await LibraryRepository.openV4ForApp(+        let (_, repository) = try await LibraryRepository.openV4ForApp(             configuration,             capabilities: .current         )-        if let repository {-            _ = try await repository.debugCounts()-        }-        // Signal success via exit status regardless of result variant-        _ = result+        // Signal success via exit status; opening and reading is the whole probe.+        _ = try await repository.debugCounts()     } } 
Packages/AsterismCore/Tests/AsterismCoreTests/BackupV4ExportTests.swift Modified +1 / -6
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/BackupV4ExportTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/BackupV4ExportTests.swiftindex 1cf2c2b..52f07fb 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/BackupV4ExportTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/BackupV4ExportTests.swift@@ -113,16 +113,11 @@ struct BackupV4ExportTests {          try LibraryRepository.publishV4Readiness(at: configuration.v4MarkerURL) -        let (result, repository) = try await LibraryRepository.openV4ForApp(+        let (_, repository) = try await LibraryRepository.openV4ForApp(             configuration, capabilities: .m4, saveStrategy: ModelContextSaveStrategy())-        guard case .ready = result, let repository else {-            Issue.record("expected a ready V4 open, got \(result)")-            throw ExportTestError.notReady-        }         return (repository, configuration, directory)     } -    private enum ExportTestError: Error { case notReady } }  // MARK: - Test Doubles
Packages/AsterismCore/Tests/AsterismCoreTests/CitedPatternUnionTests.swift Modified +1 / -5
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/CitedPatternUnionTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/CitedPatternUnionTests.swiftindex 798aab8..fa88062 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/CitedPatternUnionTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/CitedPatternUnionTests.swift@@ -214,13 +214,10 @@ private final class CitedUnionFixture {     }      func openForApp() async throws -> LibraryRepository {-        let (result, repository) = try await LibraryRepository.openV4ForApp(+        let (_, repository) = try await LibraryRepository.openV4ForApp(             configuration, capabilities: .m4,             clock: FixedRepositoryClock(Self.epoch),             saveStrategy: ModelContextSaveStrategy())-        guard case .ready = result, let repository else {-            throw CitedUnionFixtureError.notReady(String(describing: result))-        }         return repository     } @@ -320,6 +317,5 @@ private final class CitedUnionFixture { }  private enum CitedUnionFixtureError: Error {-    case notReady(String)     case rowMissing(String) }
Packages/AsterismCore/Tests/AsterismCoreTests/EntryDetailAndMergeToleranceTests.swift Modified +1 / -8
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/EntryDetailAndMergeToleranceTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/EntryDetailAndMergeToleranceTests.swiftindex 739838e..4721643 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/EntryDetailAndMergeToleranceTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/EntryDetailAndMergeToleranceTests.swift@@ -362,13 +362,10 @@ private final class ToleranceFixture {     }      func openForApp() async throws -> LibraryRepository {-        let (result, repository) = try await LibraryRepository.openV4ForApp(+        let (_, repository) = try await LibraryRepository.openV4ForApp(             configuration, capabilities: .m4,             clock: FixedRepositoryClock(Self.epoch),             saveStrategy: ModelContextSaveStrategy())-        guard case .ready = result, let repository else {-            throw ToleranceFixtureError.notReady(String(describing: result))-        }         return repository     } @@ -395,10 +392,6 @@ private final class ToleranceFixture {     } } -private enum ToleranceFixtureError: Error {-    case notReady(String)-}- private final class ToleranceSeedStore {     let context: ModelContext 
Packages/AsterismCore/Tests/AsterismCoreTests/FailClosedRegressionTests.swift Modified +1 / -8
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/FailClosedRegressionTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/FailClosedRegressionTests.swiftindex 272807f..864e405 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/FailClosedRegressionTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/FailClosedRegressionTests.swift@@ -195,13 +195,10 @@ private final class FailClosedFixture {     }      func openForApp() async throws -> LibraryRepository {-        let (result, repository) = try await LibraryRepository.openV4ForApp(+        let (_, repository) = try await LibraryRepository.openV4ForApp(             configuration, capabilities: .m4,             clock: FixedRepositoryClock(Self.epoch),             saveStrategy: ModelContextSaveStrategy())-        guard case .ready = result, let repository else {-            throw FailClosedFixtureError.notReady(String(describing: result))-        }         return repository     } @@ -222,10 +219,6 @@ private final class FailClosedFixture {     } } -private enum FailClosedFixtureError: Error {-    case notReady(String)-}- private final class FailClosedSeedStore {     let context: ModelContext 
Packages/AsterismCore/Tests/AsterismCoreTests/IdentityLookupToleranceTests.swift Modified +1 / -8
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/IdentityLookupToleranceTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/IdentityLookupToleranceTests.swiftindex 8a9d170..7ade51b 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/IdentityLookupToleranceTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/IdentityLookupToleranceTests.swift@@ -331,13 +331,10 @@ private final class LibraryFixture {     }      func openForApp() async throws -> LibraryRepository {-        let (result, repository) = try await LibraryRepository.openV4ForApp(+        let (_, repository) = try await LibraryRepository.openV4ForApp(             configuration, capabilities: .m4,             clock: FixedRepositoryClock(Self.epoch),             saveStrategy: ModelContextSaveStrategy())-        guard case .ready = result, let repository else {-            throw LibraryFixtureError.notReady(String(describing: result))-        }         return repository     } @@ -358,10 +355,6 @@ private final class LibraryFixture {     } } -private enum LibraryFixtureError: Error {-    case notReady(String)-}- private final class SeedStore {     let context: ModelContext 
Packages/AsterismCore/Tests/AsterismCoreTests/M4ToleratedFixtureTests.swift Modified +1 / -7
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/M4ToleratedFixtureTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/M4ToleratedFixtureTests.swiftindex 80f25e8..828393e 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/M4ToleratedFixtureTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/M4ToleratedFixtureTests.swift@@ -121,11 +121,8 @@ private final class M4ToleratedFixtureLibrary {         try LibraryRepository.publishV4Readiness(at: configuration.v4MarkerURL)         withExtendedLifetime(container) {} -        let (result, opened) = try await LibraryRepository.openV4ForApp(+        let (_, opened) = try await LibraryRepository.openV4ForApp(             configuration, capabilities: .m4)-        guard case .ready = result, let opened else {-            throw M4ToleratedFixtureError.notReady(String(describing: result))-        }         repository = opened     } @@ -134,6 +131,3 @@ private final class M4ToleratedFixtureLibrary {     } } -private enum M4ToleratedFixtureError: Error {-    case notReady(String)-}
Packages/AsterismCore/Tests/AsterismCoreTests/M4ToleratedScalePerformanceTests.swift Modified +1 / -7
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/M4ToleratedScalePerformanceTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/M4ToleratedScalePerformanceTests.swiftindex d0b48f1..77104e7 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/M4ToleratedScalePerformanceTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/M4ToleratedScalePerformanceTests.swift@@ -422,11 +422,8 @@ private final class M4PerformanceStore {     /// reads for the duplicated-hostname set and what the capture basis builder     /// reads through the quarantine map.     func openApp() async throws -> LibraryRepository {-        let (result, repository) = try await LibraryRepository.openV4ForApp(+        let (_, repository) = try await LibraryRepository.openV4ForApp(             configuration, capabilities: .m4)-        guard case .ready = result, let repository else {-            throw M4PerformanceStoreError.notReady(String(describing: result))-        }         return repository     } @@ -435,6 +432,3 @@ private final class M4PerformanceStore {     } } -private enum M4PerformanceStoreError: Error {-    case notReady(String)-}
Packages/AsterismCore/Tests/AsterismCoreTests/QuarantineScopingTests.swift Modified +1 / -7
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/QuarantineScopingTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/QuarantineScopingTests.swiftindex c472dff..152bc6a 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/QuarantineScopingTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/QuarantineScopingTests.swift@@ -51,12 +51,8 @@ struct QuarantineScopingTests {         try LibraryRepository.publishV4Readiness(at: configuration.v4MarkerURL)          let clock = QuarantineClock(Date(timeIntervalSince1970: 1_800_000_000))-        let (result, repository) = try await LibraryRepository.openV4ForApp(+        let (_, repository) = try await LibraryRepository.openV4ForApp(             configuration, capabilities: .m4, clock: clock, saveStrategy: ModelContextSaveStrategy())-        guard case .ready = result, let repository else {-            Issue.record("expected a ready V4 open, got \(result)")-            throw QuarantineTestError.notReady-        }         return (repository, configuration, directory)     } @@ -122,8 +118,6 @@ struct QuarantineScopingTests {     } } -private enum QuarantineTestError: Error { case notReady }- private final class QuarantineClock: RepositoryClock, @unchecked Sendable {     private let lock = NSLock()     private var value: Date
Packages/AsterismCore/Tests/AsterismCoreTests/RecentPresentationToleranceTests.swift Modified +1 / -8
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/RecentPresentationToleranceTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/RecentPresentationToleranceTests.swiftindex a4e7fe5..2fb4b78 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/RecentPresentationToleranceTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/RecentPresentationToleranceTests.swift@@ -447,13 +447,10 @@ private final class RecentToleranceFixture {     }      func openForApp() async throws -> LibraryRepository {-        let (result, repository) = try await LibraryRepository.openV4ForApp(+        let (_, repository) = try await LibraryRepository.openV4ForApp(             configuration, capabilities: .m4,             clock: FixedRepositoryClock(Self.epoch),             saveStrategy: ModelContextSaveStrategy())-        guard case .ready = result, let repository else {-            throw RecentToleranceFixtureError.notReady(String(describing: result))-        }         return repository     } @@ -480,10 +477,6 @@ private final class RecentToleranceFixture {     } } -private enum RecentToleranceFixtureError: Error {-    case notReady(String)-}- private final class RecentSeedStore {     let context: ModelContext 
Packages/AsterismCore/Tests/AsterismCoreTests/RefreshUnionInvariantTests.swift Modified +1 / -8
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/RefreshUnionInvariantTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/RefreshUnionInvariantTests.swiftindex d313749..cb9d374 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/RefreshUnionInvariantTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/RefreshUnionInvariantTests.swift@@ -393,13 +393,10 @@ private final class RefreshFixture {     }      func openForApp() async throws -> LibraryRepository {-        let (result, repository) = try await LibraryRepository.openV4ForApp(+        let (_, repository) = try await LibraryRepository.openV4ForApp(             configuration, capabilities: .m4,             clock: FixedRepositoryClock(Self.epoch),             saveStrategy: ModelContextSaveStrategy())-        guard case .ready = result, let repository else {-            throw RefreshFixtureError.notReady(String(describing: result))-        }         return repository     } @@ -420,10 +417,6 @@ private final class RefreshFixture {     } } -private enum RefreshFixtureError: Error {-    case notReady(String)-}- private final class SeedStore {     let context: ModelContext 

Things to double-check

The extension can now capture before a backup restore.

The one guard deliberately given up. Narrow — it needs a fresh install, a share, and a subsequent replace-style import — but it is a real behaviour change on a data-loss path. The mitigation is that the replace preview shows current record counts and asks twice. If you would rather keep the guard, the alternative is gating extension capture on something other than the readiness marker, which is a larger change than either ticket scoped.

V4OpeningResult / V4ExtensionResult should probably both go.

Deferred here. Two independent reviewers reached the same conclusion: both are single-case enums wrapping LibraryRecordCounts, structurally identical, and read by no production caller. Returning the counts directly would delete two types and the remaining vacuous guard case .ready sites. Worth a follow-up ticket rather than growing this branch.

The performance suites remain non-reproducible.

Unrelated to this branch but relevant if anyone measures the startup improvement it claims (three container opens down to one on first launch). Per CLAUDE.md, percentile(of:) returns the second-slowest of twenty samples, and three consecutive runs of unchanged code spanned 0.7389 s to 1.2789 s. No device target was run for this review.

Q31's file:line reference is now stale by design.

It was annotated rather than deleted, because the validator-demotion inventory in that milestone references the row. Its rationale text still names +BackupImport.swift:73, which no longer exists — the annotation says so explicitly rather than silently rewriting a dated decision.