asterism branch T-2118/empty-library commits 32 files 35 touched lines 35 files changed, 6749 insertions(+), 160 deletions(-)

Pre-push review: T-2118/empty-library

A Development-only Settings row that exports a backup, waits for a completed share, then deletes every row through the mirrored path. Reviewed against specs/empty-library/.

At a glance

  • The destructive path has exactly one route, behind a positive share-completion signal, and nothing of it reaches Personal.
  • The one device-found bug is fixed by numbering presentations (Decision 6) and confirmed by the owner on a phone.
  • This review's code fixes: no false interrupted notice after a failed lock, a bounded chunk loop, a refusal during another bulk operation, reuse of the spool's helpers, a DocumentExport log category.
  • Four documents still told the pre-fix story and were corrected.
  • Still owed, by design: runbook steps 3 to 6 on devices and a quiet-host make test-performance-m4.

Verdict

Ready to push

Four reviewers, no blocker. Every acceptance criterion is met and every divergence from the design is in the decision log. The share-outcome fix found on a phone was reviewed for the first time and judged correct, with every failure direction ending in nothing deleted. Fourteen findings fixed, five left with reasons, one pre-existing defect ticketed (T-2346). Core: 2,802 tests pass; the unit bundle with the Mac build, the feature's UI journeys and the Personal build pass at the fixed code.

Review findings

18 raised · 13 fixed · 5 skipped

Jump to findings →

Tests

Pass rate: 100% (3022 of 3022)

New tests: 71

Diff coverage: n/a

Jump to tests →

Commits

Three-level explanation

What this does

Asterism had no way back to an empty library: import only adds, and deleting is one record at a time. This adds an Empty Library row to Settings, in the Debug section of Development builds only. It counts the library and asks you to confirm, exports a backup, shows the share sheet, and only after the share sheet reports that you really handed the file off does it delete every row. Cancel anywhere before that and nothing is deleted.

Why it matters

The backup is the only way back, so the order is the whole point. If the app dies halfway, the library still opens, Settings says the emptying did not finish, and running the row again finishes it. The shipped Personal app contains neither the row nor the code that deletes everything.

Key concepts

  • Mirroring. The library syncs through iCloud, so deleting rows the ordinary way empties every device. Replacing the database file would empty one device and iCloud would refill it.
  • Chunks. Rows go 500 at a time, saving after each batch, and every save leaves a library the app can open.
  • Children before parents. Entries first, then the works and sites they pointed at.
  • Sidecar. A marker file written before the first deletion and removed when the library is empty; if it is still there at launch, the last emptying was interrupted.
  • Share outcome. iOS reports afterwards whether a share completed. On a phone that report can arrive after the sheet has closed, which caused the one bug found on a real device.

Changes overview

Core: LibraryRepository+EmptyLibrary.swift, wholly inside #if DEBUG || ASTERISM_PERFORMANCE_TESTING, with the inventory, the pass, the interrupted report and the spool's inventory()/discard(_:). App: EmptyLibraryModel (the row's state machine and sentences), BackupExportStage extracted from SettingsBackupModel, documentExporter reporting a DocumentExportOutcome through ExportOutcomeLedger, AppLibraryModel.emptyLibrary() with a drain fence and an interrupted notice, and the row in SettingsView.

Implementation approach

Seven phases run through one generic chunk loop under one exclusive lock, with bulkOperationInProgress raised and the deferred reconcile re-fired on every exit, as confirmImport does. The phase list is the single place the entity set is written and a contract test ties it to the schema. After the last phase the phases sweep again, up to three times, because the lock does not fence the mirror. Throws are reserved for refusals before anything is deleted; after the first save the pass returns a report with row counts and a content-free reason.

The row is an @Observable state machine. The confirmation is a one-shot token, the inventory the dialog presented, because SwiftUI runs a dialog's dismissal before the button's action. The share's dismissal and its outcome are two synchronous, order-independent transitions that reach .emptying exactly once. The ledger numbers presentations: a report answers the presentation it was made under whenever it arrives, an older one is dropped, and a silent dismissal is cancelled after three seconds.

Trade-offs

  • Row deletion over store replacement or a zone purge: slower, but the only empty that stays empty on every device.
  • Site rows are deleted, the one sanctioned exception to the reconcilers' rule.
  • A Site's rules go in the Site's own save; a taught Site outliving its title rule is a diagnosis the chunk introduced.
  • The share outcome fails closed: a report later than three seconds after the dismissal costs a retry, never the library.
  • Req 5.1's "no pending changes" was dropped as the importer's defect (T-2334).

Technical deep dive

LibraryValidator.siteTupleIsLegal accepts .taught only with one active title pattern, hence Decision 3: deleteSiteRules runs in the Site's chunk and the rule entities keep loops only for orphans. .workWithoutMembership between phases 2 and 5 is tolerated and never reaches a gate. Only Site.entries is detached, by ObjectIdentifier; that rewrite is quadratic in a Site's entry count over the chunk size and accepted, because clearing the array in one save is the unbounded save the order avoids. Measured over 7,009 rows: entries 71.6%, memberships 23.8%, works 4.5%. Why memberships cost 5.3× works per row is not known; an earlier note blamed a validating save strategy that exists only in tests.

completionWithItemsHandler is not ordered against a SwiftUI sheet's onDismiss. The first guard dropped any report after delivery, to stop a stale completed answering the next presentation. On a phone Save to Files always reports after the dismissal, so the guard dropped the real report. Decision 6 replaces lateness with identity. The sheet's content may be built before onChange numbers the presentation, so ShareSheet re-installs the handler on update; that ordering is reasoning, not evidence, and a dropped report now logs under category:DocumentExport.

Architecture impact

documentExporter's onCompletion now fires on the report or three seconds after the dismissal, for every caller. The backup and Markdown rows still clean up on the binding's set, which Decision 6 shows is too early (T-2346). The repository constructs a spool and an owed-marker directory from its own configuration for the first time. "Never delete a Site" has exactly one exception, written into CLAUDE.md.

Potential issues

  • Residue from a device still receiving tombstones; a second run clears it, and its dialog reads zero because it counts entries, works and sites only.
  • Activities that leave the app suspend it while the grace wait runs on a continuous clock; the hand-off may read as cancelled.
  • The macOS arm has no timeout and relies on fileExporter calling back.
  • The iOS delivery wiring has no automated net; the UI journey is green with or without the fix.

Important changes — detailed

ExportOutcomeLedger: the share outcome answers its own presentation

Asterism/Asterism/Support/PlatformModifiers.swift

Why it matters. Correctness of the only gate in front of a routine that deletes every row. The previous guard made the feature do nothing on a phone.

What to look at. PlatformModifiers.swift: ExportOutcomeLedger, DocumentExporter's iOS arm, startGraceWait

Takeaway. When two callbacks are unordered, guard by identity (which presentation) rather than by time (before or after delivery). A value type driven by a few calls turns an untestable UI ordering into unit tests.
Rationale. Decision 6: the hazard was never lateness, it was a report answering the wrong presentation.

The deletion pass: seven phases, one lock, report instead of throw

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

Why it matters. Destructive, mirrored to every device, and must leave a valid library at every commit boundary.

What to look at. emptyLibraryPhases, sweep, emptyLibrary(), deleteSiteRules, detachEntries

Takeaway. Write the entity set in one place and tie it to the schema with a contract test, so a new table cannot silently survive.
Rationale. Decisions 1 to 3, Q11, Q29, Q31. A validating save strategy in the tests is what overturned the design's phase order.

EmptyLibraryModel: a one-shot confirmation token

Asterism/Asterism/ViewModels/EmptyLibraryModel.swift

Why it matters. SwiftUI runs a confirmation dialog's dismissal before the button's action, so state at the tap cannot prove the reader saw the dialog.

What to look at. prepareConfirmation, confirm(_:), armedConfirmation

Takeaway. Hand the dialog's presenting value to the action and treat it as a token the model minted and spends once.
Rationale. Decision 5. Accepting .idle unconditionally was rejected as moving the invariant into one view.

Pre-push fixes: refusals the pass gained

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

Why it matters. A failed lock left an 'emptying did not finish' notice beside 'nothing was deleted', across launches; the chunk loop was unbounded while holding the cross-process lock.

What to look at. the do/catch around withLockedContext, the progress check in sweep, the bulkOperationInProgress guard

Takeaway. A marker written before a lock needs clearing on every path where the locked work never started.
Rationale. Q46, from this review.

BackupExportStage extracted from SettingsBackupModel

Asterism/Asterism/ViewModels/SettingsBackupModel.swift

Why it matters. Two rows share export, staging, cleanup and refusal wording without sharing a state machine; the backup row's behaviour must not change.

What to look at. BackupExportStage, ExportRefusal, scavengesOnInit

Takeaway. Extract the shared operation, not the workflow: the rows differ in their terminal states.
Rationale. Q33.

Gating: nothing reaches Personal

Asterism/Asterism/ViewModels/AppLibraryModel.swift

Why it matters. The shipped binary must contain no routine that deletes every row.

What to look at. emptyLibraryDependencies(), interruptedEmptyNotice, the #if DEBUG block

Takeaway. An argument cannot be conditionally compiled, so a declaration passed on every build stays unconditional with a gated body.
Rationale. Q22, Q40. Verified by strings over both Personal binaries.

Key decisions

Decision 1: empty means every device, through mirrored row deletion

Store replacement refills from iCloud; a zone purge was already rated unreliable.

Decision 2: Site rows are deleted too

The additive-only rule protects teaching a merge keeps; here nothing is kept. It must stay the only path that deletes a Site.

Decision 3: a Site's rules go in the Site's own save

A taught Site outliving its title rule is a .siteTuple diagnosis the chunk introduced. Seven phases, not eight.

Decision 4: Req 5.1 asserts stored state, not a clean save

The importer writes every scalar unconditionally (T-2334). This weakens an approved requirement and wants the owner's confirmation.

Decision 5: the confirmation is armed once

SwiftUI's dismissal-before-action order means state cannot carry the invariant.

Decision 6: the share outcome answers its own presentation

Supersedes Q43, whose dropped-late-report guard dropped the real report on Save to Files.

Q45 to Q47: from this review

The sharing seam logs under DocumentExport; the pass refuses during another bulk operation, clears its sidecar on a failed lock and reports a chunk that makes no progress; the older export rows' early cleanup is T-2346.

Review findings

SeverityAreaFindingResolution
majorCore pass: failed lockA run that could not take the lock left the interrupted sidecar although nothing was deleted, so Settings said 'nothing was deleted' and 'some records remain' at once, across launches.The locked call is wrapped; a throw clears the sidecar and rethrows. Test holds the real lock.
majorExport Backup and Markdown export rowsBoth delete the staged file at the sheet's dismissal, which Decision 6 shows can precede the activity finishing. Pre-existing.Changes two shipped features and their tests; ticketed as T-2346 (Q47).
majorCHANGELOG.md, design.mdBoth still described Q43's dropped-late-report guard as the shipped behaviour.Rewritten to follow Decision 6.
majorrunbook.md step 3Told the owner to read the residue in dialog counts that count entries, works and sites only, so a correct run would read as a failure.The finished sentence's row count is now the evidence.
majorverification-run.mdNo sitting for the device bug; still owed re-runs the rebase settled; blamed the membership phase on a validating save strategy that exists only in tests.Sitting added, owed list corrected, the explanation replaced with 'not known' and the reviewer's hypothesis.
majorEmptyLibraryModel.countedByte-identical private copy of Pluralisation.count.Deleted; shared helper used.
minorCore pass: chunk loopUnbounded while holding the exclusive cross-process lock; terminated only because deleted rows stop being fetched.A saved chunk that did not shrink the table is an interruption, 'no progress in <Entity>'.
minorCore pass: bulk flagemptyLibrary() raised bulkOperationInProgress unchecked and cleared it unconditionally, which would lower an import's fence.Refuses with libraryBusy before raising it.
minorSpool listingRe-implemented files(in:) and identity(ofFileAt:) more loosely; a <uuid>.txt would be counted, not removed, and reported as a cleanup failure.Built on the spool's own helpers.
minordocumentExporter loggingLogged every share in the app under category EmptyLibrary; a dropped report was silent.Own category DocumentExport, plus a notice line when a report is dropped.
minorSettings render costA second BackupExportStage, and so a second staging-directory scan, per SettingsScreen body evaluation.scavengesOnInit: false for the Empty Library stage; the two rows keep separate stages.
minorTesting gapsShareSheet's handler re-installation and AppLibraryModel.emptyLibrary()'s throwing path were untested.ShareSheetTests and a refused-pass case added.
minorDocsOVERVIEW decision count, discard's throwing signature, Req 4.1's three-second wait, Q34 punctuation, the Mac log claim, three facts missing from testing.md.All amended.
minorSettingsViewinterruptedEmptyRow copied interruptedImportRow's layout.One helper, both identifiers unchanged.
minorSettingsView failure arm, debugActionRow armsCopied from backupRow and debugActionRow rather than extracted.A refactor of shared view code for a debug row; left.
minorRow model lifetimeLeaving Settings mid-pass loses the final sentence; activities that leave the app may read as cancelled.Documented in the runbook as two things that look like failures and are not.
nitTest doublesTwo new copies of an existing BackupExporting double and a fourth fail-after-n save strategy.Test code; out of this phase's scope.
nitPhase names, counting flag, gating idiomsPhase names are strings that are also reader-visible; 'counting' is a second flag beside State; four gating idioms in one feature.Each justified in place; left.

Tests

Source: local run at 2026-09-19T23:05:00+10:00 · snapshot dc5da0de046d6d6d2a8c9f67fed41e304aa71ecf

Baseline: none

Execution: passed · JUnit: 1 file · Coverage: none · Baseline: absent

Coverage scope: every test in the repository

Totals: 3022 passed · 0 failed · 49 skipped · 0 errored · 0 flaky

New and removed tests

Derived by declaration name, from the diff (no baseline run).

Blast radius

Files that import a changed file on the left, changed files in the centre, files a changed file imports on the right. Snapshot working-tree against base 1e30f17152831817122744ba2d021121e7d441ce.

Dependents none found Changed Dependencies none found . Asterism/Asterism/Layout Asterism/Asterism/Support Asterism/Asterism/ViewModels Asterism/Asterism/Views Asterism/AsterismTests Asterism/AsterismUITests …rismCore/Sources/AsterismCore …mCore/Tests/AsterismCoreTests docs/agent-notes specs specs/empty-library CHANGELOG.mdCHANGELOG.md CLAUDE.mdCLAUDE.md MakefileMakefile Asterism/Asterism/Layout/SettingsScreen.swift…m/Layout/SettingsScreen.swift Asterism/Asterism/Support/PlatformModifiers.swift…pport/PlatformModifiers.swift Asterism/Asterism/ViewModels/AppLibraryModel.swift…wModels/AppLibraryModel.swift Asterism/Asterism/ViewModels/EmptyLibraryModel.swift…odels/EmptyLibraryModel.swift Asterism/Asterism/ViewModels/SettingsBackupModel.swift…els/SettingsBackupModel.swift Asterism/Asterism/Views/MarkdownExportShare.swift…ews/MarkdownExportShare.swift Asterism/Asterism/Views/SettingsView.swift…rism/Views/SettingsView.swift Asterism/Asterism/Views/ShareSheet.swift…terism/Views/ShareSheet.swift Asterism/AsterismTests/AppLibraryModelEmptyLibraryTests.swift…yModelEmptyLibraryTests.swift Asterism/AsterismTests/BackupExportStageTests.swift…/BackupExportStageTests.swift Asterism/AsterismTests/DocumentExportOutcomeTests.swift…umentExportOutcomeTests.swift Asterism/AsterismTests/EmptyLibraryModelTests.swift…/EmptyLibraryModelTests.swift Asterism/AsterismTests/SettingsBackupModelTests.swift…ettingsBackupModelTests.swift Asterism/AsterismTests/ShareSheetTests.swift…smTests/ShareSheetTests.swift Asterism/AsterismUITests/EmptyLibrarySettingsUITests.swift…yLibrarySettingsUITests.swift Packages/AsterismCore/Sources/AsterismCore/LibraryConfiguration.swift…re/LibraryConfiguration.swift Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+EmptyLibrary.swift…Repository+EmptyLibrary.swift Packages/AsterismCore/Sources/AsterismCore/PendingCaptureSpool.swift…ore/PendingCaptureSpool.swift Packages/AsterismCore/Tests/AsterismCoreTests/EmptyLibraryTests.swift…Tests/EmptyLibraryTests.swift Packages/AsterismCore/Tests/AsterismCoreTests/M4EmptyLibraryPerformanceTests.swift…LibraryPerformanceTests.swift Packages/AsterismCore/Tests/AsterismCoreTests/ModelContractTests.swift…ests/ModelContractTests.swift Packages/AsterismCore/Tests/AsterismCoreTests/SiteInverseReachTests.swift…s/SiteInverseReachTests.swift docs/agent-notes/testing.mddocs/agent-notes/testing.md specs/OVERVIEW.mdspecs/OVERVIEW.md specs/empty-library/decision_log.md…empty-library/decision_log.md specs/empty-library/design.mdspecs/empty-library/design.md specs/empty-library/implementation.md…pty-library/implementation.md specs/empty-library/prerequisites.md…mpty-library/prerequisites.md specs/empty-library/requirements.md…empty-library/requirements.md specs/empty-library/runbook.mdspecs/empty-library/runbook.md specs/empty-library/tasks.mdspecs/empty-library/tasks.md specs/empty-library/verification-run.md…y-library/verification-run.md
addedmodifieddeletedrenamedunchangedcollapsed package group or +N more⚑N test files with an edge to the node

Per-file diffs

Click to expand.

Asterism/Asterism/Layout/SettingsScreen.swift Modified +6 / -1
diff --git a/Asterism/Asterism/Layout/SettingsScreen.swift b/Asterism/Asterism/Layout/SettingsScreen.swiftindex 912fac29..9cfcc836 100644--- a/Asterism/Asterism/Layout/SettingsScreen.swift+++ b/Asterism/Asterism/Layout/SettingsScreen.swift@@ -68,6 +68,7 @@ struct SettingsScreen: View {             ),             syncModel: model.settingsSyncModel(),             interruptedImportNotice: model.interruptedImportNotice,+            interruptedEmptyNotice: model.interruptedEmptyNotice,             sitesModel: model.sitesListModel(),             // Req 6.2 rides the same pending-route plumbing Library Check             // uses. The flag is Decision 3's one exception, and this is the@@ -102,7 +103,11 @@ struct SettingsScreen: View {             // `DEBUG` alone, with no platform half, since a `#if os(` here             // would be one outside the four files `ipad-and-mac-layouts`             // Req 4.5 allows (see `BackgroundExportTriggerModel`).-            runBackgroundExport: { await model.runBackgroundExport() }+            runBackgroundExport: { await model.runBackgroundExport() },+            // `empty-library` Req 1.1's row, handed over on every build for the+            // reason the trigger above is: the row is what the `#if DEBUG` gate+            // withholds, and in `Personal` this is always nil.+            emptyLibrary: model.emptyLibraryDependencies()         )     } 
Asterism/Asterism/Support/PlatformModifiers.swift Modified +281 / -26
diff --git a/Asterism/Asterism/Support/PlatformModifiers.swift b/Asterism/Asterism/Support/PlatformModifiers.swiftindex cda96cd6..8177f8b7 100644--- a/Asterism/Asterism/Support/PlatformModifiers.swift+++ b/Asterism/Asterism/Support/PlatformModifiers.swift@@ -1,4 +1,5 @@ import ConstellationKit+import OSLog import PhotosUI import SwiftUI import UniformTypeIdentifiers@@ -512,42 +513,296 @@ private struct ImagePicker: ViewModifier {     #endif } -extension View {-    /// Hands a staged file to the platform's sharing surface: the system share-    /// sheet on iOS, the save panel on the Mac (Req 4.5).+/// How a sharing surface ended (`empty-library` Req 2.3, Q10).+///+/// The wrapper below used to run one closure on any dismissal, because the+/// callers' cleanup was the same whichever way it ended. Emptying the library+/// is the first caller for which it is not: it deletes on a hand-off and+/// deletes nothing on a cancel, and "the surface closed somehow" cannot tell+/// those apart. Both surfaces do report which — `UIActivityViewController`+/// through `completionWithItemsHandler`, the save panel through its result —+/// so the wrapper passes it on.+///+/// `.completed` means an activity *ran*, not that a file reached a destination:+/// Copy counts, which is why the requirement says handed off.+nonisolated enum DocumentExportOutcome: Sendable, Equatable {+    case completed+    case cancelled++    /// iOS: `completionWithItemsHandler`'s `completed` flag.+    init(activityCompleted: Bool) {+        self = activityCompleted ? .completed : .cancelled+    }++    /// macOS: the save panel's result. A failed save is a cancellation —+    /// nothing left the app either way.+    init(fileExporterResult: Result<URL, any Error>) {+        switch fileExporterResult {+        case .success: self = .completed+        case .failure: self = .cancelled+        }+    }+}++/// The iOS arm's ledger of which share presentation has been answered.+///+/// **The hazard is the ordering, and it is not hypothetical.**+/// `UIActivityViewController.completionWithItemsHandler` is not ordered against+/// the sheet's `onDismiss`, and it runs *after* it for Save to Files. That cuts+/// both ways:+///+/// - a dismissal that answers on its own reports a hand-off that really+///   happened as a cancellation — the T-2118 defect, where the phone saved the+///   archive and then emptied nothing, silently;+/// - a `.completed` left standing would answer the **next** presentation, and a+///   sheet swiped away would empty the library with nothing handed off (Q43).+///+/// Both are closed by telling presentations apart rather than by refusing late+/// reports. Every presentation takes a number and every report carries the+/// number of the presentation it came from: a report for the presentation now+/// up is delivered whenever it arrives, a report for an older one is dropped,+/// and one delivery is all a presentation gets. A dismissal answers nothing by+/// itself — it starts a grace wait, and only an expiry with nothing reported is+/// a cancellation.+///+/// A value type because the whole hazard is a sequence of calls, and a sequence+/// is something a test can drive (`DocumentExportOutcomeTests`) where neither+/// sharing surface can be.+nonisolated struct ExportOutcomeLedger {+    /// The presentation now up, or the last one to have been up. Zero before+    /// the first, which nothing can report for.+    private(set) var presentation = 0++    /// Whether ``presentation`` has been answered. True at rest, so a report+    /// arriving before any presentation answers nothing.+    private var delivered = true++    /// A presentation begins, and takes the next number.+    @discardableResult+    mutating func present() -> Int {+        presentation += 1+        delivered = false+        return presentation+    }++    /// The activity reported. The outcome to deliver, or nil when the report+    /// belongs to a presentation that is over or already answered.+    mutating func report(+        _ outcome: DocumentExportOutcome, for presentation: Int+    ) -> DocumentExportOutcome? {+        answer(outcome, for: presentation)+    }++    /// The surface closed. Nothing is answered here: the report may still be on+    /// its way. What comes back is the presentation to time out, or nil when it+    /// has already been answered and there is nothing left to wait for.+    func dismissed() -> Int? {+        delivered ? nil : presentation+    }++    /// The grace period after a dismissal ran out with nothing reported: the+    /// presentation is answered `.cancelled`, once. Nil when a report won the+    /// race, which is what the waiting is for.+    mutating func graceExpired(for presentation: Int) -> DocumentExportOutcome? {+        answer(.cancelled, for: presentation)+    }++    private mutating func answer(+        _ outcome: DocumentExportOutcome, for presentation: Int+    ) -> DocumentExportOutcome? {+        guard presentation == self.presentation, !delivered else { return nil }+        delivered = true+        return outcome+    }+}++/// Hands a staged file to the platform's sharing surface: the system share+/// sheet on iOS, the save panel on the Mac (Req 4.5).+///+/// A `ViewModifier` rather than a body, because the iOS arm needs `@State` of+/// its own: the activity controller reports its outcome either side of the+/// sheet's dismissal, and which presentation a report belongs to has to be+/// remembered between the two.+private struct DocumentExporter: ViewModifier {+    @Binding var isPresented: Bool+    let file: URL?+    let identifier: String+    let onCompletion: (DocumentExportOutcome) -> Void++    #if os(iOS)+    /// Which presentation has been answered, and with what. The report may+    /// arrive after the dismissal, so the dismissal waits rather than answering:+    /// ``ExportOutcomeLedger`` is where that ordering hazard is written down.+    @State private var ledger = ExportOutcomeLedger()++    /// The dismissal's grace wait, cancelled by a report or by the next+    /// presentation. The ledger is what actually keeps a delivery to one; this+    /// only stops a timer with nothing left to do.+    @State private var graceWait: Task<Void, Never>?++    /// How long a dismissal waits for a report that has not arrived yet.     ///-    /// `onCompletion` runs on dismissal whichever way it ends — sent, saved or-    /// cancelled. Neither surface reports which, and the callers' cleanup is-    /// the same either way.-    func documentExporter(-        isPresented: Binding<Bool>,-        file: URL?,-        identifier: String,-        onCompletion: @escaping () -> Void-    ) -> some View {-        #if os(iOS)-        return sheet(isPresented: isPresented, onDismiss: onCompletion) {-            if let file {-                ShareSheet(fileURL: file)+    /// `completionWithItemsHandler` is not ordered against the sheet's+    /// `onDismiss` — with Save to Files it runs after it — and a sheet the+    /// reader swiped away may never call it at all, so the dismissal can+    /// neither answer on its own nor wait for ever. Three seconds covers the+    /// hand-off; a report that arrives later than this is reported as+    /// cancelled, which costs the reader a retry and never the library.+    private static let lateReportGrace: Duration = .seconds(3)++    /// **This modifier's own category**, not `EmptyLibrary`'s.+    ///+    /// Which path answered a share is the one part of that row's story only+    /// this modifier knows, so it was first logged under the row's category —+    /// but the modifier serves the backup export and both Markdown exports too,+    /// and their shares then appeared inside an Empty Library trace, where a+    /// reader following Req 4.3's lines sees hand-offs that deleted nothing.+    /// Same subsystem, own category; the sheet identifier on each line says+    /// which caller it was. Reasons only — that identifier is a constant in the+    /// source, never reader content.+    private static let logger = Logger(+        subsystem: "me.nore.ig.Asterism", category: "DocumentExport")++    func body(content: Content) -> some View {+        content+            .sheet(isPresented: $isPresented, onDismiss: startGraceWait) {+                if let file {+                    // The presentation number is captured **by value** here, at+                    // build time. Read from the ledger inside the closure it+                    // would be whichever presentation is current when the+                    // handler eventually runs — which is the hazard, not the+                    // guard.+                    let presentation = ledger.presentation+                    // The handler writes into this modifier's state through the+                    // closure, never into the representable: SwiftUI rebuilds+                    // that struct whenever the body re-evaluates, and a value+                    // stored there would go with it.+                    ShareSheet(+                        fileURL: file,+                        onOutcome: { report($0, for: presentation) }+                    )                     .accessibilityIdentifier(identifier)+                }+            }+            // The presentation is what takes the next number. `ShareSheet`+            // re-installs its handler on every update, so a content build that+            // ran before this did is corrected by the rebuild this mutation+            // causes rather than answering for the presentation before it.+            .onChange(of: isPresented) { _, presented in+                if presented { beginPresentation() }+            }+    }++    /// Which of the two paths answered a presentation. A type rather than a+    /// string parameter, so the two sentences are spelled once each.+    private enum AnsweredBy {+        case activityReport+        case graceExpiry++        var sentence: String {+            switch self {+            case .activityReport: "the activity's report"+            case .graceExpiry: "the dismissal's grace period"             }         }-        #else+    }++    /// Stops a grace wait that has nothing left to do. Idempotent, and the one+    /// spelling of it: a stray `cancel()` without the `nil` leaves a finished+    /// task standing where the next `guard` reads it.+    private func cancelGraceWait() {+        graceWait?.cancel()+        graceWait = nil+    }++    private func beginPresentation() {+        cancelGraceWait()+        ledger.present()+    }++    private func report(_ completed: Bool, for presentation: Int) {+        let reported = DocumentExportOutcome(activityCompleted: completed)+        guard let outcome = ledger.report(reported, for: presentation) else {+            // A dropped report is either an older presentation's or a second+            // one for a presentation already answered — both correct, and both+            // silent until now. A genuine report going missing here is what+            // T-2118 was, so it says so: two presentation numbers, nothing else.+            Self.logger.notice(+                """+                Share report for presentation \(presentation, privacy: .public) dropped; \+                presentation \(ledger.presentation, privacy: .public) is current \+                for \(identifier, privacy: .public)+                """)+            return+        }+        cancelGraceWait()+        deliver(outcome, answeredBy: .activityReport)+    }++    /// The sheet closed, which is not an answer: with Save to Files the report+    /// is still on its way. Only an expiry with nothing reported is a cancel.+    private func startGraceWait() {+        cancelGraceWait()+        guard let presentation = ledger.dismissed() else { return }+        graceWait = Task {+            do {+                try await Task.sleep(for: Self.lateReportGrace)+            } catch {+                return+            }+            guard let outcome = ledger.graceExpired(for: presentation) else { return }+            deliver(outcome, answeredBy: .graceExpiry)+        }+    }++    private func deliver(_ outcome: DocumentExportOutcome, answeredBy path: AnsweredBy) {+        let named = outcome == .completed ? "completed" : "cancelled"+        // Composed first and logged as one public field: every part of it is a+        // constant from this file or the caller's sheet identifier.+        let sentence = "Share \(named) delivered by \(path.sentence) for \(identifier)"+        Self.logger.notice("\(sentence, privacy: .public)")+        onCompletion(outcome)+    }+    #else+    func body(content: Content) -> some View {         // `identifier` is deliberately dropped here. The save panel is a system         // window, not a subview, so there is nothing in this hierarchy to name —-        // and applying it to `self` would overwrite whatever identifier the-        // wrapped content already carried, which is how a UI test loses a-        // screen. The parameter stays in the signature because the iOS arm's-        // sheet is a real view and the callers name it.-        return fileExporter(-            isPresented: isPresented,+        // and applying it to the content would overwrite whatever identifier it+        // already carried, which is how a UI test loses a screen. The parameter+        // stays in the signature because the iOS arm's sheet is a real view and+        // the callers name it.+        content.fileExporter(+            isPresented: $isPresented,             document: file.map(StagedFileDocument.init(fileURL:)),             contentTypes: [exportContentType(of: file)],             defaultFilename: file?.lastPathComponent,-            onCompletion: { _ in onCompletion() },-            onCancellation: onCompletion+            onCompletion: { onCompletion(DocumentExportOutcome(fileExporterResult: $0)) },+            onCancellation: { onCompletion(.cancelled) }         )-        #endif+    }+    #endif+}++extension View {+    /// Hands a staged file to the platform's sharing surface: the system share+    /// sheet on iOS, the save panel on the Mac (Req 4.5).+    ///+    /// `onCompletion` runs once per presentation, on the main actor, and is+    /// told which way it ended: a caller with the same cleanup for both ignores+    /// the argument and takes the presentation binding's `set` as its+    /// dismissal. On iOS it runs when the surface reports, which for Save to+    /// Files is after the sheet has closed, or when the dismissal's grace+    /// period runs out with nothing reported (T-2118).+    func documentExporter(+        isPresented: Binding<Bool>,+        file: URL?,+        identifier: String,+        onCompletion: @escaping (DocumentExportOutcome) -> Void+    ) -> some View {+        modifier(+            DocumentExporter(+                isPresented: isPresented, file: file, identifier: identifier,+                onCompletion: onCompletion))     }      /// Takes a cover image in from the platform's own picker
Asterism/Asterism/ViewModels/AppLibraryModel.swift Modified +157 / -5
diff --git a/Asterism/Asterism/ViewModels/AppLibraryModel.swift b/Asterism/Asterism/ViewModels/AppLibraryModel.swiftindex ce459c34..78fbaee8 100644--- a/Asterism/Asterism/ViewModels/AppLibraryModel.swift+++ b/Asterism/Asterism/ViewModels/AppLibraryModel.swift@@ -174,6 +174,32 @@ public final class AppLibraryModel {     /// file by then and a notice about a finished import is just wrong.     public private(set) var interruptedImport: InterruptedImportReport? +    #if DEBUG+    /// What an interrupted emptying left behind, read beside `interruptedImport`+    /// at the end of every bootstrap (`empty-library` Req 3.4, Q25).+    ///+    /// Its own sidecar and its own notice rather than a discriminator on the+    /// import's: the import's remedy is "import the same backup again" and this+    /// one's is "run Empty Library again", and one file with two meanings would+    /// need two sentences behind one identifier.+    private var interruptedEmpty: InterruptedEmptyReport?++    /// Q35's fence. While an emptying is in flight the drain returns before it+    /// starts a pass: the emptying is synchronous inside the repository actor, so+    /// a drain's commits would queue up and land in the library it has just+    /// emptied, and its refused attempts would count against each record's+    /// attempt budget for nothing.+    private(set) var emptyLibraryInFlight = false++    /// Test seam for that fence, on `setBulkOperationInProgressForTesting`'s+    /// terms: over a test-sized library the pass is finished within the first+    /// poll, so the only honest way to ask what a drain does *while* the fence is+    /// up is to raise it and ask.+    func setEmptyLibraryInFlightForTesting(_ inFlight: Bool) {+        emptyLibraryInFlight = inFlight+    }+    #endif+     // MARK: - Preserved captures (pending-capture-queue)      /// Req 2.5's sentence, or nil when nothing is waiting.@@ -526,6 +552,12 @@ public final class AppLibraryModel {             self.suggestions = Self.makeSuggestionCoordinator(library: repo)             self.characterExtraction = Self.makeCharacterExtractionCoordinator(library: repo)             interruptedImport = await repo.interruptedImport()+            #if DEBUG+            // `empty-library` Req 3.4, read here because it is the same kind of+            // fact and the same moment: a sidecar at open means a destructive+            // pass stopped partway, whatever left it.+            interruptedEmpty = await repo.interruptedEmpty()+            #endif             // Before `refreshAll()`, so a capture this pass commits is in the             // first snapshots rather than waiting for the next activation, and             // before `scheduleLaunchReconcile()` below, so reconciliation cannot@@ -860,6 +892,13 @@ public final class AppLibraryModel {     /// caller and the reader was told twice about it.     func drainPendingCaptures(budget: TimeInterval = PendingCaptureBounds.drainPassTimeBudget) async {         guard let drain = pendingCaptureDrain, let spool = pendingCaptureSpool else { return }+        #if DEBUG+        // Q35 of `empty-library`: before the watcher bracket, because a drain+        // that never runs has no pass for the watcher to hold arrivals across.+        // The next foreground activation retries, as it does for every pass this+        // guard turns away.+        guard !emptyLibraryInFlight else { return }+        #endif         // The watcher holds its arrivals for the length of the pass and re-diffs         // against the filenames as they were when it started (Decision 4). That         // is what stops this pass's own rewrites — `recordAttempt`, `setAside`,@@ -2269,14 +2308,23 @@ public final class AppLibraryModel {      /// Provides a settings backup model backed by the current repository.     public func settingsBackupModel() -> SettingsBackupModel? {+        guard let exporter = backupExporter() else { return nil }+        return SettingsBackupModel(exporter: exporter)+    }++    /// The backup exporter both rows that export a backup are built over — the+    /// Export Backup row and, in `Development`, Empty Library.+    ///+    /// A fresh exporter per call; what the two rows share is the **staging+    /// directory** it is pointed at, so either row's scavenge sweeps whatever+    /// the other abandoned. Which means only one of them needs to sweep it:+    /// the backup row's stage does, and Empty Library's is built with+    /// `scavengesOnInit: false` because both are constructed in the same render.+    private func backupExporter() -> BackupV14Exporter? {         guard let repo = backupRepository, let config = resolvedConfiguration else { return nil }         let stagingDir = config.rootDirectory             .appending(path: "Library/Caches/BackupExports")-        let exporter = BackupV14Exporter(-            repository: repo,-            stagingDirectory: stagingDir-        )-        return SettingsBackupModel(exporter: exporter)+        return BackupV14Exporter(repository: repo, stagingDirectory: stagingDir)     }      /// The per-entry markdown export model (Req 1.5), staged beside the backup@@ -2349,6 +2397,110 @@ public final class AppLibraryModel {             + "restoring adds and updates, so running it twice is safe."     } +    // MARK: - Emptying the library (`empty-library`, Development only)++    /// Req 3.4's sentence, or nil when no emptying stopped partway.+    ///+    /// **Always nil in `Personal`**, which has neither the row nor the pass+    /// behind it (Req 1.1): the property itself is unconditional only because+    /// `SettingsScreen` hands it over on every build, exactly as it hands over+    /// `runBackgroundExport`.+    ///+    /// No date, and reported however old the sidecar is, for the reason+    /// `interruptedImportNotice` gives: an emptying that stopped partway did not+    /// become complete by being ignored.+    public var interruptedEmptyNotice: String? {+        #if DEBUG+        guard interruptedEmpty != nil else { return nil }+        return "Emptying the library did not finish. Some records remain. "+            + "Run Empty Library again to remove them."+        #else+        return nil+        #endif+    }++    /// The Empty Library row's inputs, or nil when there is nothing to empty.+    ///+    /// Nil in `Personal` whatever the library is doing: the pass and its report+    /// types are compiled out of Core there (Q22), so there is nothing to hand+    /// over and the row is not built.+    func emptyLibraryDependencies() -> EmptyLibraryDependencies? {+        #if DEBUG+        guard repository is LibraryRepository, let exporter = backupExporter()+        else { return nil }+        return EmptyLibraryDependencies(+            // **No scavenge here**: `settingsBackupModel()` builds its own stage+            // over the same staging directory in the same body evaluation, and+            // that one sweeps it. Both are rebuilt on every `SettingsScreen`+            // render, so the second scan was a second directory walk per render+            // for a directory that had just been walked.+            stage: BackupExportStage(exporter: exporter, scavengesOnInit: false),+            // Resolved through `self`, like `empty` below, rather than over a+            // repository captured when the row was built: the row outlives a+            // teardown, and counting a library this model has since closed+            // would report on a library the emptying would not touch.+            inventory: { [weak self] in+                guard let repo = self?.repository as? LibraryRepository else {+                    throw LibraryRepositoryError.libraryUnavailable(+                        operation: "counting the library", reason: "no library is open")+                }+                return try await repo.emptyLibraryInventory()+            },+            empty: { [weak self] in+                guard let self else {+                    throw LibraryRepositoryError.libraryUnavailable(+                        operation: "emptying the library", reason: "the library was closed")+                }+                return try await self.emptyLibrary()+            })+        #else+        return nil+        #endif+    }++    #if DEBUG+    /// Runs the deletion pass and settles everything that described the library+    /// it emptied (Reqs 3.4, 3.5, 3.7, 3.8).+    ///+    /// The drain in flight is awaited before the pass rather than merely fenced+    /// (Q35), which is what `teardownRepository` does for the same reason: its+    /// commits belong to the library that is about to go, and the pass must not+    /// start while one is half-made.+    func emptyLibrary() async throws -> EmptyLibraryReport {+        guard let repo = repository as? LibraryRepository else {+            throw LibraryRepositoryError.libraryUnavailable(+                operation: "emptying the library", reason: "no library is open")+        }+        // Set before the first suspension, so a drain requested from anywhere+        // after this statement finds the fence up.+        emptyLibraryInFlight = true+        defer { emptyLibraryInFlight = false }+        _ = await pendingCapturePassTask?.value++        do {+            let report = try await repo.emptyLibrary()+            await settleAfterEmptying(repo)+            return report+        } catch {+            await settleAfterEmptying(repo)+            throw error+        }+    }++    /// Whatever the outcome, every surface that described the old library is+    /// re-derived: the diagnoses and snapshots (Req 3.8), the drain report about+    /// captures that no longer exist, and the preserved-capture counts the pass+    /// has just discarded (Req 3.7).+    private func settleAfterEmptying(_ repo: LibraryRepository) async {+        // Re-read rather than cleared: a completed pass removed the sidecar and+        // an interrupted one left it, so this is the notice either way.+        interruptedEmpty = await repo.interruptedEmpty()+        await refreshDiagnosesAndSnapshots()+        dismissDrainReport()+        await refreshPendingCaptureSurfaces()+    }+    #endif+     /// Seeds a composed store: an untaught actionable Entry (for teaching through     /// the composed surface) plus a composed-taught Site whose URL rule supplies     /// Work identity (for Work detail → Review URL identity → Recalculate).
Asterism/Asterism/ViewModels/EmptyLibraryModel.swift Added +324 / -0
diff --git a/Asterism/Asterism/ViewModels/EmptyLibraryModel.swift b/Asterism/Asterism/ViewModels/EmptyLibraryModel.swiftnew file mode 100644index 00000000..c942abd6--- /dev/null+++ b/Asterism/Asterism/ViewModels/EmptyLibraryModel.swift@@ -0,0 +1,324 @@+import AsterismCore+import Foundation+import OSLog++/// The Empty Library row's inputs, handed to `SettingsView` through+/// `SettingsScreen` the way `runBackgroundExport` is.+///+/// **Unconditional, with gated members.** An initializer parameter cannot be+/// `#if`-gated, so the type has to exist in `Personal` for `SettingsView.init`+/// to name it; what `Personal` withholds is everything inside it, because the+/// pass and its two report types are compiled out of Core there (Q22). The+/// struct is then empty and `AppLibraryModel` never builds one — which is the+/// same shape the row itself has.+struct EmptyLibraryDependencies {+    #if DEBUG+    let stage: BackupExportStage+    let inventory: @MainActor () async throws -> EmptyLibraryInventory+    let empty: @MainActor () async throws -> EmptyLibraryReport+    #endif+}++#if DEBUG+/// The state machine behind the `Development`-only Empty Library row: count,+/// confirm, export, hand off, delete, report (`empty-library` Reqs 1.2–4.2).+///+/// The whole file is behind `#if DEBUG`, like `DebugActionTriggerModel.swift`:+/// `DEBUG` is set on the `Development` configuration only, so a `Personal` build+/// has neither this type nor the row that shows it.+///+/// **Not `DebugActionTriggerModel`**, though the row renders on its shape. That+/// model is one action and one sentence; this one waits on the reader twice —+/// at the dialog and at the sharing surface — and the second wait is what Req+/// 2.3 turns on: nothing is deleted until the archive has left the app.+///+/// Every sentence the row shows comes from here, which is `SettingsView`'s+/// convention.+@MainActor+@Observable+final class EmptyLibraryModel {+    /// Req 4.3's category, shared with the pass in Core. Each transition and the+    /// outcome is `.notice` — counts and reasons only, never reader content —+    /// and a failure is `.error`.+    ///+    /// **Not `.debug`**: a debug line is hidden in Console by default and is+    /// never persisted, so a row driven before Console was streaming leaves no+    /// evidence of what it did.+    private static let logger = Logger(+        subsystem: "me.nore.ig.Asterism", category: "EmptyLibrary")++    enum State: Equatable {+        case idle+        /// The dialog is up over the counts it was built from (Req 1.2).+        case confirming(EmptyLibraryInventory)+        case exporting+        /// The archive is staged and the sharing surface is up.+        case sharing+        /// The surface closed and the outcome has not been delivered yet (Q34).+        ///+        /// On iOS this is where the row waits out the exporter's grace period —+        /// up to three seconds — because `completionWithItemsHandler` runs after+        /// the sheet's dismissal for Save to Files (T-2118). It shows a progress+        /// sentence and accepts no second tap while it does (Req 4.1).+        case shareDismissed+        case emptying+        /// The outcome, worded.+        case finished(String)+        case failed(message: String, routesToCheckLibrary: Bool)+    }++    private(set) var state: State = .idle++    private let stage: BackupExportStage+    private let inventory: @MainActor () async throws -> EmptyLibraryInventory+    private let empty: @MainActor () async throws -> EmptyLibraryReport++    /// The counts the dialog now up was armed with, and the one-shot token+    /// `confirm(_:)` spends.+    ///+    /// `confirm` has to accept `.idle` (see its own note), so the state machine+    /// alone cannot tell the dialog's own button from any other call arriving in+    /// that state. The armed inventory can: the dialog hands its `presenting:`+    /// value back to the action closure, and only a call carrying the value this+    /// model armed the dialog with proceeds.+    private var armedConfirmation: EmptyLibraryInventory?++    /// Whether a count is in flight, so a second tap during the read is ignored+    /// rather than starting a second one (Req 4.1).+    ///+    /// The state machine cannot cover this: the row is still `.idle` until the+    /// count answers, which is precisely the window.+    private var counting = false++    init(+        stage: BackupExportStage,+        inventory: @escaping @MainActor () async throws -> EmptyLibraryInventory,+        empty: @escaping @MainActor () async throws -> EmptyLibraryReport+    ) {+        self.stage = stage+        self.inventory = inventory+        self.empty = empty+    }++    /// The archive waiting for the sharing surface, which the stage owns — and+    /// observes, so reading through it redraws the row.+    var stagedFileURL: URL? { stage.stagedFileURL }++    /// What the confirmation dialog is presented over, or nil when it is not up.+    var pendingConfirmation: EmptyLibraryInventory? {+        guard case .confirming(let inventory) = state else { return nil }+        return inventory+    }++    /// The row's phase while one is in flight (Req 4.1), or nil when the row is+    /// waiting for the reader.+    var progressSentence: String? {+        switch state {+        case .exporting: "Exporting a backup…"+        case .sharing, .shareDismissed: "Waiting for the backup to be saved…"+        case .emptying: "Emptying the library…"+        default: nil+        }+    }++    // MARK: - The dialog (Reqs 1.2, 1.3)++    /// Counts the library and raises the confirmation.+    ///+    /// Accepted from `idle` and from both terminal states — a second run is how+    /// an interrupted emptying is resumed — and ignored while a phase is in+    /// flight, or while the count itself is, which is Req 4.1's "SHALL NOT+    /// accept a second tap".+    func prepareConfirmation() async {+        switch state {+        case .idle, .finished, .failed: break+        case .confirming, .exporting, .sharing, .shareDismissed, .emptying: return+        }+        guard !counting else { return }+        counting = true+        defer { counting = false }+        do {+            let counted = try await inventory()+            armedConfirmation = counted+            state = .confirming(counted)+        } catch {+            armedConfirmation = nil+            fail("The library could not be counted: \(Self.reason(error)).")+        }+    }++    /// Req 1.3: nothing is exported, written or deleted.+    ///+    /// **The armed token is deliberately left standing.** SwiftUI runs this —+    /// through the presentation binding's `set` — *before* the destructive+    /// button's own action, so clearing it here would disarm the very tap it is+    /// meant to authorise. What retires it is spending it, and a fresh+    /// `prepareConfirmation` replacing it.+    func cancelConfirmation() {+        guard case .confirming = state else { return }+        state = .idle+    }++    // MARK: - The export (Reqs 2.1, 2.2)++    /// Exports the backup and hands it to the sharing surface. A refusal fails+    /// the row and deletes nothing (Req 2.2).+    ///+    /// **Accepted from `.idle` as well as from `.confirming`**, and it has to+    /// be: SwiftUI runs a confirmation dialog's *dismissal* before it runs the+    /// tapped button's action, so the binding's `set` has already cancelled the+    /// confirmation by the time this is called. That is the same fact the house+    /// `presenting:` pattern exists for — and this is that pattern: the dialog+    /// hands back the inventory it was built from, and a call carrying anything+    /// else, or arriving with nothing armed, is not the dialog's button.+    ///+    /// The token is spent here, so one armed dialog exports once. What the state+    /// switch still refuses is a tap that arrives while a phase is in flight+    /// (Req 4.1).+    func confirm(_ inventory: EmptyLibraryInventory) async {+        switch state {+        case .idle, .confirming: break+        case .exporting, .sharing, .shareDismissed, .emptying, .finished, .failed: return+        }+        guard armedConfirmation == inventory else { return }+        armedConfirmation = nil+        state = .exporting+        Self.logger.notice("Empty library: exporting the backup")+        switch await stage.export() {+        case .success:+            state = .sharing+            Self.logger.notice("Empty library: the archive is on the sharing surface")+        case .failure(let refusal):+            Self.logger.notice("Empty library refused: the backup export was refused")+            state = .failed(+                message: refusal.message, routesToCheckLibrary: refusal.routesToCheckLibrary)+        }+    }++    // MARK: - The sharing surface (Reqs 2.3, 2.4, Q34)++    /// The surface closed. Synchronous, so `state == .sharing` reads false+    /// before SwiftUI re-evaluates the presentation and re-presents it.+    func shareDismissed() {+        guard state == .sharing else { return }+        state = .shareDismissed+        Self.logger.notice("Empty library: the sharing surface closed")+    }++    /// What the surface reported. Order-independent with `shareDismissed` (Q34):+    /// iOS writes the binding first and its report may follow seconds later+    /// (T-2118), the Mac may deliver the result first, and both transitions+    /// leave `.sharing` before any await.+    func handleShareOutcome(_ outcome: DocumentExportOutcome) {+        guard state == .sharing || state == .shareDismissed else { return }+        switch outcome {+        case .completed:+            // Req 2.4: no further prompt. The pass is started from here rather+            // than awaited, because this runs from a SwiftUI callback.+            state = .emptying+            Self.logger.notice("Empty library: the share completed; emptying started")+            Task { await runPass() }+        case .cancelled:+            // Req 2.3: nothing is deleted, and the staged archive goes as the+            // Export Backup row's cancellation makes it go.+            stage.cleanup()+            state = .idle+            Self.logger.notice("Empty library: the share was cancelled; nothing is deleted")+        }+    }++    // MARK: - The pass (Reqs 3.10, 4.2)++    private func runPass() async {+        do {+            let report = try await empty()+            // Q28: the staged archive outlives the share and goes here, at the+            // terminal state — "completed" means an activity ran, and until the+            // rows are gone the file is the only local copy.+            stage.cleanup()+            switch report.outcome {+            case .completed:+                Self.logger.notice(+                    "Empty library finished: \(report.rowsDeleted, privacy: .public) rows removed")+                state = .finished(Self.completedSentence(report))+            case .interrupted(let phase, let reason):+                fail(Self.interruptedSentence(report, phase: phase, reason: reason))+            }+        } catch {+            // Q29: the pass throws only before the first save, so nothing is+            // gone — and the archive that was staged for a deletion that never+            // happened goes with the row's terminal state.+            stage.cleanup()+            fail("Emptying the library failed: \(Self.reason(error)). Nothing was deleted.")+        }+    }++    private func fail(_ message: String, routesToCheckLibrary: Bool = false) {+        // The message itself, which Req 4.3 asks for: every sentence that+        // reaches here is built from counts, phase names and an error+        // *category* (`reason(_:)`), never from a record. A line that said only+        // "failed" left the reason on the screen and nowhere in Console.+        Self.logger.error("Empty library failed: \(message, privacy: .public)")+        state = .failed(message: message, routesToCheckLibrary: routesToCheckLibrary)+    }++    /// The one way an `Error` reaches a sentence here: by category, never by+    /// description.+    ///+    /// A raw description names the record the failure happened on — a work's+    /// title, a site's hostname — and these sentences are shown on screen and+    /// stand beside a log line Req 4.3 holds to counts and reasons.+    /// `BackupExportStage.diagnosticCategory(for:)` is where that mapping+    /// already lives, pinned by `BackupExportStageTests`.+    private static func reason(_ error: Error) -> String {+        BackupExportStage.diagnosticCategory(for: error)+    }++    // MARK: - Wording++    static let confirmationTitle = "Empty the library?"++    /// Req 1.2's message: the three row counts, the two spool counts, the+    /// every-device consequence, and the backup that goes first.+    ///+    /// The counts are physical rows (Q16) and informative: the deletion removes+    /// whatever the library holds when it runs.+    static func confirmationMessage(_ inventory: EmptyLibraryInventory) -> String {+        let rows = "\(Pluralisation.count(inventory.entries, "entry", "entries")), "+            + "\(Pluralisation.count(inventory.works, "work", "works")) and "+            + "\(Pluralisation.count(inventory.sites, "site", "sites"))"+        let captures = inventory.waitingCaptures + inventory.setAsideCaptures == 0+            ? "No captures are waiting to be discarded."+            : "\(inventory.waitingCaptures) waiting and \(inventory.setAsideCaptures) "+                + "set-aside captures are discarded with them."+        return "\(rows) will be deleted. \(captures) "+            + "Every device signed into this iCloud account is emptied when it next syncs. "+            + "A backup is exported first, and nothing is deleted until you have saved it."+    }++    /// Req 3.10: completion is local, and the outcome says so.+    private static func completedSentence(_ report: EmptyLibraryReport) -> String {+        let removed = "Removed \(Pluralisation.count(report.rowsDeleted, "row", "rows"))"+        switch report.cleanup {+        case .done, .skipped:+            return "\(removed). Other devices empty when they next sync."+        case .failed(let reason):+            return "\(removed), but some pending captures or markers could not be removed: "+                + "\(reason)."+        }+    }++    /// The phase is quoted rather than read as prose: Core's phase names carry+    /// their ordinal (`1 entries`, `7 sites and rules`) because the order is the+    /// contract, and "before 1 entries failed" reads as a count. The name is the+    /// pass's to choose, so the sentence goes around it.+    private static func interruptedSentence(+        _ report: EmptyLibraryReport, phase: String, reason: String+    ) -> String {+        "Removed \(Pluralisation.count(report.rowsDeleted, "row", "rows")); "+            + "phase \"\(phase)\" failed: \(reason). "+            + "\(Pluralisation.count(report.remaining, "row", "rows")) remain. "+            + "Run Empty Library again to remove them."+    }+}+#endif
Asterism/Asterism/ViewModels/SettingsBackupModel.swift Modified +159 / -71
diff --git a/Asterism/Asterism/ViewModels/SettingsBackupModel.swift b/Asterism/Asterism/ViewModels/SettingsBackupModel.swiftindex a6b356c6..f5220f76 100644--- a/Asterism/Asterism/ViewModels/SettingsBackupModel.swift+++ b/Asterism/Asterism/ViewModels/SettingsBackupModel.swift@@ -20,63 +20,71 @@ public protocol BackupExporting: Sendable {  extension BackupV14Exporter: BackupExporting {} -// MARK: - Settings Backup View Model+// MARK: - Export Stage -/// Drives the Settings backup surface: export progress, failure reporting, and-/// system share presentation. Does not add restore or markdown export.-@MainActor @Observable-public final class SettingsBackupModel {-    private static let logger = Logger(subsystem: "me.nore.ig.Asterism", category: "SettingsBackupModel")--    // MARK: - State--    public enum State: Equatable, Sendable {-        case idle-        case exporting-        case sharing-        case failed-    }--    public private(set) var state: State = .idle-    public private(set) var exportedFileURL: URL?-    public private(set) var errorMessage: String?+/// Why an export did not produce an archive: the sentence the reader is shown,+/// and whether there is somewhere for them to go.+nonisolated struct ExportRefusal: Error, Equatable, Sendable {+    let message: String -    /// Whether the failure has somewhere for the reader to go (Req 8.4).+    /// Whether the failure has somewhere for the reader to go (Req 8.4 of+    /// `polish-and-export`).     ///     /// Only a torn-groups refusal does: every other export failure is either     /// transient or a state no screen in the app can act on, and a button-    /// offering a route to neither is worse than no button. `SettingsView`-    /// renders it beside Retry.-    public private(set) var routesToCheckLibrary = false+    /// offering a route to neither is worse than no button.+    let routesToCheckLibrary: Bool+} -    // MARK: - Dependencies+/// The export itself — running it, holding the staged archive, removing it, and+/// the wording of a refusal (Q33 of `empty-library`).+///+/// Extracted from `SettingsBackupModel` when a second row gained a reason to+/// export a backup: Empty Library exports one before it deletes anything, and+/// the two rows share the export but not the terminal states — the backup row+/// returns to idle once the archive has been handed off, the empty row+/// proceeds to delete. One workflow machine over both would carry a flag the+/// other never sets, so each row keeps its own and drives this.+///+/// Both rows therefore show one sentence for one refusal, which is what makes+/// `empty-library` Req 2.2 hold by construction rather than by copying.+///+/// `@Observable` because `stagedFileURL` is read through both rows' computed+/// properties and is what their sharing surfaces are handed. Without it the+/// staged file changed under SwiftUI unobserved, and the rows only redrew+/// because their own state happened to move in the same statement.+@MainActor @Observable final class BackupExportStage {+    private static let logger = Logger(+        subsystem: "me.nore.ig.Asterism", category: "BackupExportStage")      private let exporter: any BackupExporting-    private var isExporting = false     private var currentResult: BackupExportResult? -    // MARK: - Init+    /// The archive waiting on disk for the sharing surface, while one is staged.+    private(set) var stagedFileURL: URL? -    /// Creates the model and immediately scavenges any stale backup files from prior sessions.-    public init(exporter: any BackupExporting) {+    /// Builds the stage and immediately scavenges any staged file a prior+    /// session abandoned. One staging directory, so one sweep serves both rows.+    ///+    /// `scavengesOnInit: false` is for the second stage built in the same+    /// render: the Settings screen builds one for the Export Backup row and,+    /// in `Development`, one for Empty Library, on every body evaluation, and+    /// the two sweep the same directory. The rows keep **separate stages** —+    /// each owns one staged file, and sharing one would let either row's+    /// cleanup or share presentation act on the other's — so it is the+    /// duplicated directory scan that goes, not the second stage.+    init(exporter: any BackupExporting, scavengesOnInit: Bool = true) {         self.exporter = exporter-        // Clean up abandoned files older than 24 hours on startup-        exporter.scavengeStaleFiles()+        if scavengesOnInit { exporter.scavengeStaleFiles() }     } -    // MARK: - Export--    /// Starts a backup export. Suppresses duplicate submissions while already in flight.-    /// On success, transitions to `.sharing` with the file URL ready for system share.-    /// On failure, transitions to `.failed` with a privacy-safe diagnostic message.-    public func startExport() async {-        guard !isExporting else { return }-        isExporting = true-        state = .exporting-        errorMessage = nil-        routesToCheckLibrary = false-        exportedFileURL = nil+    /// Exports an archive, returning where it was staged or why it was refused.+    ///+    /// Any file staged by an earlier export is dropped first: a refusal must+    /// leave nothing behind for a caller to hand off.+    func export() async -> Result<URL, ExportRefusal> {         currentResult = nil+        stagedFileURL = nil          do {             let metadata = BackupV14Metadata(@@ -85,44 +93,37 @@ public final class SettingsBackupModel {             )             let result = try await exporter.export(metadata: metadata)             currentResult = result-            exportedFileURL = result.fileURL-            state = .sharing+            stagedFileURL = result.fileURL             Self.logger.debug("Backup export succeeded")+            return .success(result.fileURL)         } catch {-            state = .failed-            // Privacy-safe: log only the error category, never user content-            errorMessage = Self.privacySafeMessage(for: error)-            if let exportError = error as? BackupV14ExportError,-               case .tornGroups = exportError {-                routesToCheckLibrary = true-            }-            Self.logger.error("Backup export failed: \(Self.diagnosticCategory(for: error), privacy: .public)")+            // Privacy-safe: log only the error category, never user content.+            Self.logger.error(+                "Backup export failed: \(Self.diagnosticCategory(for: error), privacy: .public)")+            return .failure(+                ExportRefusal(+                    message: Self.privacySafeMessage(for: error),+                    routesToCheckLibrary: Self.routesToCheckLibrary(for: error)))         }--        isExporting = false-    }--    // MARK: - Share Lifecycle--    /// Called when the user completes sharing (dismiss/send). Cleans up the staged file.-    public func handleShareCompletion() {-        cleanupAndReset()     } -    /// Called when the user cancels the share sheet. Cleans up the staged file.-    public func handleShareCancellation() {-        cleanupAndReset()-    }--    // MARK: - Private--    private func cleanupAndReset() {+    /// Removes the staged archive. Idempotent: a second call has nothing to+    /// remove, which is the normal case when two callbacks reach the same row.+    func cleanup() {         if let result = currentResult {             exporter.cleanup(result)         }         currentResult = nil-        exportedFileURL = nil-        state = .idle+        stagedFileURL = nil+    }++    // MARK: - Refusal wording++    private static func routesToCheckLibrary(for error: Error) -> Bool {+        guard let exportError = error as? BackupV14ExportError,+              case .tornGroups = exportError+        else { return false }+        return true     }      /// Returns the current app's build number from the main bundle, or "unknown" for tests.@@ -247,3 +248,90 @@ public final class SettingsBackupModel {         }     } }++// MARK: - Settings Backup View Model++/// Drives the Settings backup surface: export progress, failure reporting, and+/// system share presentation. Does not add restore or markdown export.+@MainActor @Observable+public final class SettingsBackupModel {++    // MARK: - State++    public enum State: Equatable, Sendable {+        case idle+        case exporting+        case sharing+        case failed+    }++    public private(set) var state: State = .idle+    public private(set) var errorMessage: String?++    /// Whether the failure has somewhere for the reader to go (Req 8.4);+    /// `SettingsView` renders it beside Retry.+    public private(set) var routesToCheckLibrary = false++    /// The staged archive, which the stage owns.+    ///+    /// Not mirrored into a stored property here: the stage is `@Observable`, so+    /// reading through it is observed, and a second copy could only disagree+    /// with the file the stage actually holds.+    public var exportedFileURL: URL? { stage.stagedFileURL }++    // MARK: - Dependencies++    private let stage: BackupExportStage+    private var isExporting = false++    // MARK: - Init++    /// Creates the model over a stage, which scavenges any stale backup files+    /// from prior sessions as it is built.+    public init(exporter: any BackupExporting) {+        stage = BackupExportStage(exporter: exporter)+    }++    // MARK: - Export++    /// Starts a backup export. Suppresses duplicate submissions while already in flight.+    /// On success, transitions to `.sharing` with the file URL ready for system share.+    /// On failure, transitions to `.failed` with a privacy-safe diagnostic message.+    public func startExport() async {+        guard !isExporting else { return }+        isExporting = true+        state = .exporting+        errorMessage = nil+        routesToCheckLibrary = false++        switch await stage.export() {+        case .success:+            state = .sharing+        case .failure(let refusal):+            state = .failed+            errorMessage = refusal.message+            routesToCheckLibrary = refusal.routesToCheckLibrary+        }++        isExporting = false+    }++    // MARK: - Share Lifecycle++    /// Called when the user completes sharing (dismiss/send). Cleans up the staged file.+    public func handleShareCompletion() {+        cleanupAndReset()+    }++    /// Called when the user cancels the share sheet. Cleans up the staged file.+    public func handleShareCancellation() {+        cleanupAndReset()+    }++    // MARK: - Private++    private func cleanupAndReset() {+        stage.cleanup()+        state = .idle+    }+}
Asterism/Asterism/Views/MarkdownExportShare.swift Modified +4 / -2
diff --git a/Asterism/Asterism/Views/MarkdownExportShare.swift b/Asterism/Asterism/Views/MarkdownExportShare.swiftindex e139fc95..4468a9b1 100644--- a/Asterism/Asterism/Views/MarkdownExportShare.swift+++ b/Asterism/Asterism/Views/MarkdownExportShare.swift@@ -26,8 +26,10 @@ private struct MarkdownExportShare: ViewModifier {             identifier: sheetIdentifier,             // The binding's `set` above already is the dismissal handler:             // both surfaces write `false` back when they close, so cleanup has-            // one home rather than two that can disagree.-            onCompletion: {}+            // one home rather than two that can disagree. The outcome is+            // ignored here — a markdown export stages a file either way, and+            // cleanup is the same whether the reader sent it or not.+            onCompletion: { _ in }         )     } }
Asterism/Asterism/Views/SettingsView.swift Modified +172 / -6
diff --git a/Asterism/Asterism/Views/SettingsView.swift b/Asterism/Asterism/Views/SettingsView.swiftindex 38257daf..573135ac 100644--- a/Asterism/Asterism/Views/SettingsView.swift+++ b/Asterism/Asterism/Views/SettingsView.swift@@ -38,6 +38,9 @@ struct SettingsView: View {     /// and the one action it names — import the backup again — is the section     /// it sits beside.     private let interruptedImportNotice: String?+    /// `empty-library` Req 3.4's report, worded by `AppLibraryModel` like the+    /// one above it. Always nil in `Personal`, which has no pass to interrupt.+    private let interruptedEmptyNotice: String?     /// Req 6.1's list. A plain `let` like `diagnosticsModel`: the screen it     /// pushes owns the loaded state, so nothing here needs to survive a     /// re-render.@@ -88,6 +91,14 @@ struct SettingsView: View {     /// every configuration and the *row* is what the gate withholds.     private let runBackgroundExport: (() async -> BackgroundExportOutcome)? +    #if DEBUG+    /// `empty-library`'s row, owned here for the reason `backgroundExportTrigger`+    /// is: Settings' inputs are rebuilt on every re-render of the presenting+    /// view, and a model rebuilt with them would forget which phase it is in —+    /// which for this row means dropping a staged archive mid-share.+    @State private var emptyLibraryModel: EmptyLibraryModel?+    #endif+     #if DEBUG     /// The state machine that closure drives, owned here for the reason     /// `syncModel` is: Settings' inputs are rebuilt on every re-render of the@@ -109,6 +120,7 @@ struct SettingsView: View {         diagnosticsModel: LibraryDiagnosticsModel? = nil,         syncModel: SettingsSyncModel? = nil,         interruptedImportNotice: String? = nil,+        interruptedEmptyNotice: String? = nil,         sitesModel: SitesListModel? = nil,         siteDetailModel: SiteDetailModelBuilder? = nil,         workTypesModel: WorkTypesModel? = nil,@@ -119,7 +131,8 @@ struct SettingsView: View {         onDeleteSetAsideCapture: ((UUID) -> Void)? = nil,         onDismissDrainReport: (() -> Void)? = nil,         onOpenDrainedEntry: ((UUID) -> Void)? = nil,-        runBackgroundExport: (() async -> BackgroundExportOutcome)? = nil+        runBackgroundExport: (() async -> BackgroundExportOutcome)? = nil,+        emptyLibrary: EmptyLibraryDependencies? = nil     ) {         _model = State(initialValue: model)         _importModel = State(initialValue: importModel)@@ -127,6 +140,7 @@ struct SettingsView: View {         self.isAwaitingFirstSync = isAwaitingFirstSync         self.diagnosticsModel = diagnosticsModel         self.interruptedImportNotice = interruptedImportNotice+        self.interruptedEmptyNotice = interruptedEmptyNotice         self.sitesModel = sitesModel         self.siteDetailModel = siteDetailModel         self.workTypesModel = workTypesModel@@ -146,10 +160,59 @@ struct SettingsView: View {                 // the other.                 DebugActionTriggerModel(action: { "Last pass: \(await pass().logDescription)" })             })+        _emptyLibraryModel = State(+            initialValue: emptyLibrary.map {+                EmptyLibraryModel(stage: $0.stage, inventory: $0.inventory, empty: $0.empty)+            })         #endif     }      var body: some View {+        #if DEBUG+        // `empty-library`: the row's dialog and its sharing surface, attached to+        // the `List` rather than to the row — the lazy-row rule every other+        // presentation on this screen follows. Compiled out of `Personal` with+        // the row itself (Req 1.1).+        listContent+            .confirmationDialog(+                EmptyLibraryModel.confirmationTitle,+                isPresented: Binding(+                    get: { emptyLibraryModel?.pendingConfirmation != nil },+                    set: { if !$0 { emptyLibraryModel?.cancelConfirmation() } }),+                titleVisibility: .visible,+                presenting: emptyLibraryModel?.pendingConfirmation+            ) { inventory in+                // The `presenting:` value is handed back to the action, as it is+                // on the set-aside delete below — and here it is also the token+                // the model armed the dialog with, because SwiftUI has already+                // run the dismissal by the time this closure does and `confirm`+                // has to accept `.idle`.+                Button("Empty Library", role: .destructive) {+                    Task { await emptyLibraryModel?.confirm(inventory) }+                }+                .accessibilityIdentifier("settings-empty-library-confirm")+                Button("Cancel", role: .cancel) { emptyLibraryModel?.cancelConfirmation() }+                    .accessibilityIdentifier("settings-empty-library-cancel")+            } message: { inventory in+                Text(EmptyLibraryModel.confirmationMessage(inventory))+            }+            // Unlike the backup row's exporter above, both callbacks are wired+            // (Q34): the binding's `set` is the dismissal and `onCompletion`+            // carries which way it ended, because this row deletes on a hand-off+            // and deletes nothing on a cancel.+            .documentExporter(+                isPresented: Binding(+                    get: { emptyLibraryModel?.state == .sharing },+                    set: { if !$0 { emptyLibraryModel?.shareDismissed() } }),+                file: emptyLibraryModel?.stagedFileURL,+                identifier: "settings-empty-library-share-sheet",+                onCompletion: { outcome in emptyLibraryModel?.handleShareOutcome(outcome) })+        #else+        listContent+        #endif+    }++    private var listContent: some View {         List {             // Sites leads (T-2117): site management is the reader-facing             // control; the sync status that used to sit here is debug-tier now@@ -162,6 +225,7 @@ struct SettingsView: View {              Section {                 interruptedImportRow+                interruptedEmptyRow                 backupRow                 if let importModel {                     SettingsBackupImportView(@@ -225,8 +289,10 @@ struct SettingsView: View {                 set: { if !$0 { model.handleShareCompletion() } }),             file: model.exportedFileURL,             identifier: "settings-backup-share-sheet",-            // The binding's `set` above already is the dismissal handler.-            onCompletion: {}+            // The binding's `set` above already is the dismissal handler, and+            // the outcome is ignored: this row's cleanup is the same whether+            // the archive was sent or the sheet was cancelled.+            onCompletion: { _ in }         )         // Req 6.10's delete asks first, like every other destructive action in the         // app. `presenting:` hands the destructive button the row the dialog was@@ -273,6 +339,7 @@ struct SettingsView: View {                     diagnosticsRow                     backgroundExportRow                     thumbnailProbeRow+                    emptyLibraryRow                 } label: {                     Text("Debug")                         .font(.subheadline)@@ -358,17 +425,36 @@ struct SettingsView: View {     /// stopped partway did not become complete by being ignored for a month.     @ViewBuilder     private var interruptedImportRow: some View {-        if let interruptedImportNotice {+        interruptedRow(interruptedImportNotice, identifier: "settings-interrupted-import-notice")+    }++    /// `empty-library` Req 3.4, on the notice above's shape and beside it.+    ///+    /// Its own row rather than a second meaning for that one (Q25): the import's+    /// remedy is importing the same backup again, and this one's is running+    /// Empty Library again, so one identifier would stand for two sentences.+    /// Two sentences and two identifiers, one shape — which is this helper, not+    /// a second copy of it.+    @ViewBuilder+    private var interruptedEmptyRow: some View {+        interruptedRow(interruptedEmptyNotice, identifier: "settings-interrupted-empty-notice")+    }++    /// The shape both notices take: an amber circle and one sentence, nothing+    /// to dismiss, absent when there is nothing to say.+    @ViewBuilder+    private func interruptedRow(_ notice: String?, identifier: String) -> some View {+        if let notice {             HStack(alignment: .firstTextBaseline, spacing: 6) {                 Image(systemName: "exclamationmark.circle")                     .font(.caption)                     .foregroundStyle(AsterismColors.amberText)                     .accessibilityHidden(true)-                Text(interruptedImportNotice)+                Text(notice)                     .font(.callout)             }             .accessibilityElement(children: .combine)-            .accessibilityIdentifier("settings-interrupted-import-notice")+            .accessibilityIdentifier(identifier)         }     } @@ -634,6 +720,86 @@ struct SettingsView: View {         #endif     } +    /// `empty-library` Req 1.1: the last row in the Debug disclosure, and the+    /// only destructive one.+    ///+    /// `debugActionRow`'s state switch plus two arms it has no use for: the+    /// sharing surface the row waits on between the export and the deletion+    /// (Req 2.3), and `backupRow`'s failure arm, because a refused export is+    /// reported here exactly as it is there, Check Library route included+    /// (Req 2.2). Every sentence comes from the model.+    @ViewBuilder+    private var emptyLibraryRow: some View {+        #if DEBUG+        if let emptyLibraryModel {+            switch emptyLibraryModel.state {+            case .idle, .confirming:+                Button(role: .destructive) {+                    Task { await emptyLibraryModel.prepareConfirmation() }+                } label: {+                    Label("Empty Library…", systemImage: "trash")+                }+                .accessibilityIdentifier("settings-empty-library-run")++            case .exporting, .sharing, .shareDismissed, .emptying:+                HStack {+                    ProgressView()+                        .accessibilityIdentifier("settings-empty-library-progress")+                    Text(emptyLibraryModel.progressSentence ?? "")+                        .foregroundStyle(.secondary)+                }++            case .finished(let sentence):+                VStack(alignment: .leading, spacing: 8) {+                    // On the `Text` rather than on the stack: a container's+                    // identifier is inherited by every descendant and would take+                    // the button's name with it.+                    Text(sentence)+                        .font(.callout)+                        .textSelection(.enabled)+                        .accessibilityIdentifier("settings-empty-library-result")+                    Button {+                        Task { await emptyLibraryModel.prepareConfirmation() }+                    } label: {+                        Text("Empty Library…")+                    }+                    .buttonStyle(.borderless)+                    .accessibilityIdentifier("settings-empty-library-run")+                }++            case .failed(let message, let routesToCheckLibrary):+                VStack(alignment: .leading, spacing: 8) {+                    Text(message)+                        .font(.callout)+                        .textSelection(.enabled)+                        .accessibilityIdentifier("settings-empty-library-result")+                    HStack(spacing: 16) {+                        Button {+                            Task { await emptyLibraryModel.prepareConfirmation() }+                        } label: {+                            Text("Try Again")+                        }+                        .accessibilityIdentifier("settings-empty-library-retry")++                        // Beside Retry rather than instead of it, and only where+                        // the refusal has somewhere to go — `backupRow`'s rule,+                        // and the same sentence decides it.+                        if routesToCheckLibrary, let diagnosticsModel {+                            NavigationLink {+                                LibraryDiagnosticsView(model: diagnosticsModel)+                            } label: {+                                Text("Check Library")+                            }+                            .accessibilityIdentifier("settings-empty-library-check-library")+                        }+                    }+                    .buttonStyle(.borderless)+                }+            }+        }+        #endif+    }+     #if DEBUG     /// One row, following `backupRow`'s state switch: the button, the action     /// running, then what it did.
Asterism/Asterism/Views/ShareSheet.swift Modified +31 / -1
diff --git a/Asterism/Asterism/Views/ShareSheet.swift b/Asterism/Asterism/Views/ShareSheet.swiftindex a2e6f486..d00169c8 100644--- a/Asterism/Asterism/Views/ShareSheet.swift+++ b/Asterism/Asterism/Views/ShareSheet.swift@@ -14,9 +14,20 @@ import UIKit struct ShareSheet: UIViewControllerRepresentable {     let fileURL: URL +    /// Whether an activity ran to completion (`empty-library` Req 2.3).+    ///+    /// Reported while the sheet is still up or, with Save to Files, after it has+    /// closed: `completionWithItemsHandler` is not ordered against the+    /// presenting sheet's `onDismiss` (T-2118). Either way the closure writes+    /// into the presenting modifier's state rather than being answered from+    /// here: this struct is rebuilt on every body evaluation, and the closure it+    /// carries names the presentation it belongs to.+    let onOutcome: (Bool) -> Void+     func makeUIViewController(context: Context) -> UIActivityViewController {         let controller = UIActivityViewController(             activityItems: [fileURL], applicationActivities: nil)+        install(into: controller)         // The anchor a regular-width presentation needs. Presented inside a         // sheet the controller fills it and never asks, but a popover         // presentation without a source view is a crash, and the iPad is the@@ -33,6 +44,25 @@ struct ShareSheet: UIViewControllerRepresentable {         return controller     } -    func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {}+    /// The handler is re-installed on every update, not only at construction.+    ///+    /// The closure it carries answers for one presentation, and SwiftUI may+    /// build this struct for the first time before the presenting modifier has+    /// numbered that presentation. Keeping the live handler the newest one means+    /// the sheet answers for the presentation it is actually showing, in+    /// whichever order the two were evaluated.+    func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {+        install(into: uiViewController)+    }++    /// `internal` rather than private so `ShareSheetTests` can install two+    /// sheets' handlers into one real controller and prove the newer closure is+    /// the one that answers (Decision 6). Neither sharing surface can be driven+    /// from a test; the installation can.+    func install(into controller: UIActivityViewController) {+        controller.completionWithItemsHandler = { _, completed, _, _ in+            onOutcome(completed)+        }+    } } #endif
Asterism/AsterismTests/AppLibraryModelEmptyLibraryTests.swift Added +223 / -0
diff --git a/Asterism/AsterismTests/AppLibraryModelEmptyLibraryTests.swift b/Asterism/AsterismTests/AppLibraryModelEmptyLibraryTests.swiftnew file mode 100644index 00000000..4ed92773--- /dev/null+++ b/Asterism/AsterismTests/AppLibraryModelEmptyLibraryTests.swift@@ -0,0 +1,223 @@+import AsterismCore+import Foundation+import Testing+@testable import Asterism++/// The app half of `empty-library`: where the pass is called from, what it+/// settles afterwards, and the two things that have to stay out of its way+/// (Reqs 3.4, 3.5, 3.7, 3.8, Q35).+///+/// Driven through a real bootstrap over a temporary root rather than an injected+/// double, for `AppLibraryModelPendingCaptureTests`' reason: the pass takes a+/// concrete `LibraryRepository`, and what is under test is the wiring — that a+/// drain in flight is awaited, that a drain requested during the pass never+/// starts, and that every surface describing the old library is re-derived+/// whatever the outcome.+@Suite("AppLibraryModel empty library")+@MainActor+struct AppLibraryModelEmptyLibraryTests {++    private static func temporaryRoot() -> URL {+        FileManager.default.temporaryDirectory.appending(path: "asterism-empty-\(UUID())")+    }++    private static let shareInstant = MillisecondInstant.quantize(+        Date(timeIntervalSince1970: 1_770_000_000))++    @discardableResult+    private static func preserve(_ url: String, into root: URL) async throws -> UUID {+        let spool = PendingCaptureSpool(rootDirectory: root)+        let id = UUID()+        try await spool.preserve(+            SharePayload(providerURL: url, hostTitle: "Chapter 7"), sharedAt: shareInstant, id: id)+        return id+    }++    // MARK: - What the pass settles (Reqs 3.7, 3.8)++    /// After the pass: no diagnoses, no waiting count, no set-aside rows and no+    /// drain report — every one of those described the library that has gone.+    @Test("An emptying clears the diagnoses, the drain report and the capture surfaces")+    func emptyingSettlesEverySurface() async throws {+        let root = Self.temporaryRoot()+        defer { try? FileManager.default.removeItem(at: root) }+        try await Self.preserve("https://ex.test/read/1", into: root)++        let model = AppLibraryModel(configuration: LibraryConfiguration(rootDirectory: root))+        await model.bootstrap()+        #expect(model.state == .ready)+        // The launch drain committed the capture and reported it, so there is+        // something on each surface to clear.+        #expect(model.drainReportNotice != nil)+        #expect(!model.todayPresentation.allRows.isEmpty)++        let report = try await model.emptyLibrary()++        #expect(report.isCompleted)+        #expect(report.rowsDeleted > 0)+        #expect(model.todayPresentation.allRows.isEmpty, "the snapshots were republished")+        #expect(model.drainReport == nil)+        #expect(model.drainReportNotice == nil)+        #expect(model.pendingCaptureWaitingNotice == nil)+        #expect(model.setAsideCaptureRows.isEmpty)+        #expect(model.worksSnapshot.works.isEmpty)+        #expect(model.worksSnapshot.unattachedEntries.isEmpty)+        #expect(!model.emptyLibraryInFlight, "Q35's fence comes down with the pass")+    }++    /// Idempotent from the app's side too: a second run over an emptied library+    /// deletes nothing and reports that it did.+    @Test("A second run over an emptied library reports zero rows")+    func aSecondRunReportsZeroRows() async throws {+        let root = Self.temporaryRoot()+        defer { try? FileManager.default.removeItem(at: root) }+        let model = AppLibraryModel(configuration: LibraryConfiguration(rootDirectory: root))+        await model.bootstrap()+        _ = try await model.emptyLibrary()++        let second = try await model.emptyLibrary()++        #expect(second.isCompleted)+        #expect(second.rowsDeleted == 0)+    }++    /// The refusal path (Q29, Q30): the pass throws only before anything is+    /// deleted, and the app model's bookkeeping still has to settle around it.+    /// The row shows the failure and the reader carries on, so a fence left up+    /// or a surface left stale would outlive a pass that did nothing.+    @Test("A refused pass lowers the drain fence and still settles the surfaces")+    func aRefusedPassStillSettles() async throws {+        let root = Self.temporaryRoot()+        defer { try? FileManager.default.removeItem(at: root) }+        let configuration = LibraryConfiguration(rootDirectory: root)+        try await Self.preserve("https://ex.test/read/4", into: root)++        let model = AppLibraryModel(configuration: configuration)+        await model.bootstrap()+        // The launch drain committed that capture and reported it, so there is+        // something for the post-outcome settle to clear.+        #expect(model.drainReportNotice != nil)+        try await Self.preserve("https://ex.test/read/5", into: root)++        // Q30's refusal, arranged as the Core suite arranges it: a directory+        // where the sidecar's file belongs, so the atomic write fails and the+        // pass refuses before the lock and before the first deletion.+        try FileManager.default.createDirectory(+            at: configuration.emptySidecarURL, withIntermediateDirectories: true)++        await #expect(throws: LibraryRepositoryError.self) {+            _ = try await model.emptyLibrary()+        }++        #expect(!model.emptyLibraryInFlight, "Q35's fence comes down on the throwing path too")+        #expect(model.drainReport == nil, "the post-outcome refreshes ran anyway")+        #expect(model.drainReportNotice == nil)+        #expect(!model.todayPresentation.allRows.isEmpty, "nothing was deleted")++        // And the fence is really down, not merely reported down: the next+        // drain starts a pass and commits the capture waiting behind it.+        await model.drainPendingCaptures()+        #expect(model.drainReport?.newEntries == 1)+    }++    // MARK: - The drain fence (Q35)++    /// A drain requested while the pass runs returns before it starts one: the+    /// record is not attempted, nothing is committed into the library being+    /// emptied, and no report is published. The same drain once the fence is+    /// down does commit it, which is what says the guard is what turned it away.+    @Test("A drain requested while the emptying runs never starts a pass")+    func aDrainDuringTheEmptyingNeverRuns() async throws {+        let root = Self.temporaryRoot()+        defer { try? FileManager.default.removeItem(at: root) }+        let model = AppLibraryModel(configuration: LibraryConfiguration(rootDirectory: root))+        await model.bootstrap()+        try await Self.preserve("https://ex.test/read/2", into: root)++        model.setEmptyLibraryInFlightForTesting(true)+        await model.drainPendingCaptures()++        #expect(model.drainReport == nil, "the fenced drain published nothing")+        let fenced = try await PendingCaptureSpool(rootDirectory: root).pending()+        #expect(fenced.count == 1, "nothing was committed into the library being emptied")+        #expect(fenced.first?.attempts == 0, "the fenced drain never attempted it")++        model.setEmptyLibraryInFlightForTesting(false)+        await model.drainPendingCaptures()++        #expect(model.drainReport?.newEntries == 1, "the next drain commits it as usual")+    }++    /// The other half of Q35: a drain already in flight is *awaited*, not+    /// fenced, so the pass never starts on a half-made commit. What the test can+    /// see is the consequence — the capture that drain committed is deleted with+    /// everything else, and the library is empty afterwards.+    @Test("A drain in flight is awaited before the pass runs")+    func aDrainInFlightIsAwaited() async throws {+        let root = Self.temporaryRoot()+        defer { try? FileManager.default.removeItem(at: root) }+        let model = AppLibraryModel(configuration: LibraryConfiguration(rootDirectory: root))+        await model.bootstrap()+        try await Self.preserve("https://ex.test/read/3", into: root)++        let drain = Task { await model.drainPendingCaptures() }+        let report = try await model.emptyLibrary()+        await drain.value++        #expect(report.isCompleted)+        #expect(model.todayPresentation.allRows.isEmpty, "nothing the drain committed survived")+    }++    // MARK: - The interrupted notice (Req 3.4)++    /// A sidecar at open is a destructive pass that stopped partway, and the+    /// notice says what to do about it: run the row again.+    @Test("A sidecar at bootstrap raises the notice, and a completed run clears it")+    func theInterruptedNoticeAppearsAndClears() async throws {+        let root = Self.temporaryRoot()+        defer { try? FileManager.default.removeItem(at: root) }+        let configuration = LibraryConfiguration(rootDirectory: root)+        try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)+        try JSONEncoder().encode(InterruptedEmptyReport(startedAt: .now))+            .write(to: configuration.emptySidecarURL)++        let model = AppLibraryModel(configuration: configuration)+        await model.bootstrap()++        let notice = try #require(model.interruptedEmptyNotice)+        #expect(notice.contains("did not finish"))+        #expect(notice.contains("Empty Library again"))++        _ = try await model.emptyLibrary()++        #expect(model.interruptedEmptyNotice == nil)+        #expect(!FileManager.default.fileExists(atPath: configuration.emptySidecarURL.path))+    }++    @Test("A library that never stopped an emptying shows no notice")+    func noSidecarMeansNoNotice() async {+        let root = Self.temporaryRoot()+        defer { try? FileManager.default.removeItem(at: root) }+        let model = AppLibraryModel(configuration: LibraryConfiguration(rootDirectory: root))+        await model.bootstrap()++        #expect(model.interruptedEmptyNotice == nil)+    }++    // MARK: - The row's inputs++    /// The row is built over the same exporter construction the backup row uses,+    /// so the two share one staging directory rather than scavenging past each+    /// other's staged files.+    @Test("The dependencies are offered once the library is open, and not before")+    func theDependenciesFollowTheOpenLibrary() async {+        let root = Self.temporaryRoot()+        defer { try? FileManager.default.removeItem(at: root) }+        let model = AppLibraryModel(configuration: LibraryConfiguration(rootDirectory: root))+        #expect(model.emptyLibraryDependencies() == nil, "nothing is open yet")++        await model.bootstrap()++        #expect(model.emptyLibraryDependencies() != nil)+    }+}
Asterism/AsterismTests/BackupExportStageTests.swift Added +262 / -0
diff --git a/Asterism/AsterismTests/BackupExportStageTests.swift b/Asterism/AsterismTests/BackupExportStageTests.swiftnew file mode 100644index 00000000..c9023a11--- /dev/null+++ b/Asterism/AsterismTests/BackupExportStageTests.swift@@ -0,0 +1,262 @@+import AsterismCore+import Foundation+import Testing+@testable import Asterism++/// The export, staging and refusal wording the two rows that export a backup+/// share (Q33): Export Backup, and Empty Library, which exports one before it+/// deletes anything (Reqs 2.1 and 2.2).+///+/// `SettingsBackupModelTests` still covers the backup row's state machine over+/// this stage, unchanged. What is pinned here is the half both rows read: the+/// staged URL, the refusal's sentence and its route, and that cleanup removes+/// the file whether it is called once or twice.+@Suite("Backup export stage")+struct BackupExportStageTests {++    // MARK: - Staging++    @Test("A successful export returns the staged URL and holds it")+    @MainActor func exportReturnsTheStagedURL() async throws {+        let staging = TemporaryStaging()+        defer { staging.remove() }+        let staged = staging.write("Asterism-backup-stage.json")++        let exporter = RecordingBackupExporter()+        exporter.exportResult = .success(BackupExportResult(fileURL: staged))+        let stage = BackupExportStage(exporter: exporter)++        let result = await stage.export()++        #expect(try result.get() == staged)+        #expect(stage.stagedFileURL == staged)+        #expect(exporter.exportCallCount == 1)+    }++    /// The staging directory is swept on construction, so a file abandoned by a+    /// process that died mid-share does not outlive the next launch.+    @Test("The stage scavenges stale files when it is built")+    @MainActor func scavengesOnInit() {+        let exporter = RecordingBackupExporter()+        _ = BackupExportStage(exporter: exporter)+        #expect(exporter.scavengeCallCount == 1)+    }++    // MARK: - Refusals++    @Test("A refusal carries the message and, for torn groups, the route")+    @MainActor func tornRefusalRoutesToCheckLibrary() async {+        let exporter = RecordingBackupExporter()+        exporter.exportResult = .failure(+            BackupV14ExportError.tornGroups(+                TornGroupsPayload(count: 3, blockingWorkSet: nil)))+        let stage = BackupExportStage(exporter: exporter)++        guard case .failure(let refusal) = await stage.export() else {+            Issue.record("Expected the export to refuse")+            return+        }++        #expect(refusal.message.contains("3"))+        #expect(refusal.message.contains("Check Library"))+        #expect(refusal.routesToCheckLibrary)+        // Nothing is staged by a refusal, so there is nothing to hand to a+        // sharing surface and nothing for the empty row to proceed on.+        #expect(stage.stagedFileURL == nil)+    }++    /// Every other refusal is either transient or a state no screen can act on,+    /// and a button routing to neither is worse than no button.+    @Test("Every other refusal has a message and no route", arguments: [+        BackupV14ExportError.referencesStillArriving(detail: "rule 1"),+        BackupV14ExportError.unrepresentableValue(+            record: "Character", field: "factsData", value: "…"),+        BackupV14ExportError.snapshotFailed(reason: "read"),+        BackupV14ExportError.encodingFailed(reason: "encode"),+        BackupV14ExportError.stagingFailed(reason: "stage"),+    ])+    @MainActor func otherRefusalsOfferNoRoute(error: BackupV14ExportError) async {+        let exporter = RecordingBackupExporter()+        exporter.exportResult = .failure(error)+        let stage = BackupExportStage(exporter: exporter)++        guard case .failure(let refusal) = await stage.export() else {+            Issue.record("Expected the export to refuse")+            return+        }++        #expect(!refusal.message.isEmpty)+        #expect(!refusal.routesToCheckLibrary)+        // Privacy holds by construction: the reason strings never reach the+        // reader, on either row.+        #expect(!refusal.message.contains("rule 1"))+        #expect(!refusal.message.contains("factsData"))+    }++    /// The one refusal that names a record, and the reason it may: a cover is+    /// the reader's to remove, so the sentence has to say which work+    /// (`work-thumbnails` Req 8.3, Q14). Nothing else about the record travels.+    @Test("Only the cover refusal names a record, and only its title")+    @MainActor func coverRefusalNamesTheWorkAndNothingElse() async {+        let exporter = RecordingBackupExporter()+        exporter.exportResult = .failure(+            BackupV14ExportError.unrepresentableValue(+                record: "Work “Actual Title”",+                field: BackupV14ExportError.thumbnailField,+                value: "17 bytes that are not a portrait cover"))+        let stage = BackupExportStage(exporter: exporter)++        guard case .failure(let refusal) = await stage.export() else {+            Issue.record("Expected the export to refuse")+            return+        }++        #expect(refusal.message.contains("Actual Title"))+        #expect(refusal.message.contains("Remove the cover"))+        #expect(!refusal.message.contains("17 bytes"))+        #expect(!refusal.routesToCheckLibrary)+    }++    /// The log line beside the message, where the title must **not** go: it is+    /// emitted `privacy: .public`, so the field name is the category and the+    /// title stays on screen.+    ///+    /// The refusal's `description` names its record — `Work “<title>”` for a+    /// cover, `Site <hostname>` for a site mode a newer build wrote. Logging the+    /// description would put a work the reader is reading, or a host they read+    /// it on, into a log any process on the device can read, for a failure whose+    /// category is all a maintainer needs.+    @Test("The logged category names the field, never the record")+    @MainActor func diagnosticCategoryCarriesNoReaderContent() {+        let cover = BackupExportStage.diagnosticCategory(+            for: BackupV14ExportError.unrepresentableValue(+                record: "Work “Actual Title”",+                field: BackupV14ExportError.thumbnailField,+                value: "17 bytes that are not a portrait cover"))++        #expect(cover == "export: unrepresentable thumbnail")+        #expect(!cover.contains("Actual Title"))+        #expect(!cover.contains("17 bytes"))++        // The same hole the cover arm would have opened has been standing on+        // the site arm since the refusal was written.+        let site = BackupExportStage.diagnosticCategory(+            for: BackupV14ExportError.unrepresentableValue(+                record: "Site reader.example", field: "mode", value: "hexagonal"))++        #expect(site == "export: unrepresentable mode")+        #expect(!site.contains("reader.example"))+        #expect(!site.contains("hexagonal"))++        // The refusals that name no record keep their whole description: a+        // count and a route carry nothing of the reader's.+        let torn = BackupExportStage.diagnosticCategory(+            for: BackupV14ExportError.tornGroups(+                TornGroupsPayload(count: 2, blockingWorkSet: nil)))+        #expect(torn.hasPrefix("export: "))+        #expect(torn.contains("2 records"))+    }++    // MARK: - Cleanup++    @Test("Cleanup removes the staged file and forgets it")+    @MainActor func cleanupRemovesTheStagedFile() async {+        let staging = TemporaryStaging()+        defer { staging.remove() }+        let staged = staging.write("Asterism-backup-cleanup.json")++        let exporter = RecordingBackupExporter()+        exporter.exportResult = .success(BackupExportResult(fileURL: staged))+        let stage = BackupExportStage(exporter: exporter)+        _ = await stage.export()++        stage.cleanup()++        #expect(!FileManager.default.fileExists(atPath: staged.path))+        #expect(stage.stagedFileURL == nil)+        #expect(exporter.cleanupCallCount == 1)+        #expect(exporter.lastCleanupURL == staged)+    }++    /// Both rows can reach cleanup twice — the share surface's two callbacks on+    /// the empty row (Q34), a retry after a cancel on the backup row — so a+    /// second call has to be a no-op rather than a second removal attempt.+    @Test("Cleanup is idempotent")+    @MainActor func cleanupIsIdempotent() async {+        let staging = TemporaryStaging()+        defer { staging.remove() }+        let staged = staging.write("Asterism-backup-twice.json")++        let exporter = RecordingBackupExporter()+        exporter.exportResult = .success(BackupExportResult(fileURL: staged))+        let stage = BackupExportStage(exporter: exporter)+        _ = await stage.export()++        stage.cleanup()+        stage.cleanup()++        #expect(exporter.cleanupCallCount == 1)+        #expect(stage.stagedFileURL == nil)+    }++    @Test("Cleanup before any export does nothing")+    @MainActor func cleanupWithNothingStaged() {+        let exporter = RecordingBackupExporter()+        let stage = BackupExportStage(exporter: exporter)++        stage.cleanup()++        #expect(exporter.cleanupCallCount == 0)+        #expect(stage.stagedFileURL == nil)+    }+}++// MARK: - Test Doubles++/// A staging directory with a real file in it, so cleanup can be observed on+/// the file system rather than only on a call count.+private struct TemporaryStaging {+    let directory: URL++    init() {+        directory = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString)+        try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)+    }++    func write(_ name: String) -> URL {+        let url = directory.appending(path: name)+        try? Data("{}".utf8).write(to: url)+        return url+    }++    func remove() {+        try? FileManager.default.removeItem(at: directory)+    }+}++/// Records what the stage asked of the exporter, and — unlike the backup row's+/// mock — removes the staged file on cleanup, as `BackupV14Exporter` does.+private final class RecordingBackupExporter: BackupExporting, @unchecked Sendable {+    var exportCallCount = 0+    var cleanupCallCount = 0+    var scavengeCallCount = 0+    var lastCleanupURL: URL?++    var exportResult: Result<BackupExportResult, Error> = .failure(+        BackupV14ExportError.snapshotFailed(reason: "not configured"))++    func export(metadata: BackupV14Metadata) async throws -> BackupExportResult {+        exportCallCount += 1+        return try exportResult.get()+    }++    func cleanup(_ result: BackupExportResult) {+        cleanupCallCount += 1+        lastCleanupURL = result.fileURL+        try? FileManager.default.removeItem(at: result.fileURL)+    }++    func scavengeStaleFiles() {+        scavengeCallCount += 1+    }+}
Asterism/AsterismTests/DocumentExportOutcomeTests.swift Added +118 / -0
diff --git a/Asterism/AsterismTests/DocumentExportOutcomeTests.swift b/Asterism/AsterismTests/DocumentExportOutcomeTests.swiftnew file mode 100644index 00000000..b11c065d--- /dev/null+++ b/Asterism/AsterismTests/DocumentExportOutcomeTests.swift@@ -0,0 +1,118 @@+import Foundation+import Testing+@testable import Asterism++/// The sharing surface's completion signal (Req 2.3, Q10).+///+/// Both initialisers are pure, and they are the whole portable part of the+/// signal: each platform's handler wiring — `completionWithItemsHandler` on+/// iOS, the `fileExporter` result on the Mac — is the manual arm, because+/// neither surface can be driven from a test. What is asserted here is the+/// mapping the emptying hangs on: a wrong answer either deletes a library the+/// reader never saved a backup of, or never deletes one they did.+@Suite("Document export outcome")+struct DocumentExportOutcomeTests {++    // MARK: - iOS: the activity controller's flag++    @Test("An activity that ran to completion is a completion")+    func activityCompletedIsCompleted() {+        #expect(DocumentExportOutcome(activityCompleted: true) == .completed)+    }++    /// The share sheet reports `false` for a dismissal with no activity picked,+    /// which is a cancel by any reading.+    @Test("An activity that did not complete is a cancellation")+    func activityNotCompletedIsCancelled() {+        #expect(DocumentExportOutcome(activityCompleted: false) == .cancelled)+    }++    // MARK: - macOS: the save panel's result++    @Test("A save panel that returns a URL is a completion")+    func fileExporterSuccessIsCompleted() {+        let saved = URL(fileURLWithPath: "/tmp/Asterism-backup.json")+        #expect(DocumentExportOutcome(fileExporterResult: .success(saved)) == .completed)+    }++    /// A save that failed left nothing outside the app, so it is a cancel+    /// rather than a completion the reader cannot act on.+    @Test("A save panel that returns an error is a cancellation")+    func fileExporterFailureIsCancelled() {+        let failed = Result<URL, any Error>.failure(CocoaError(.fileWriteNoPermission))+        #expect(DocumentExportOutcome(fileExporterResult: failed) == .cancelled)+    }++    // MARK: - The ledger, across presentations++    /// Nothing can be answered before a presentation has begun.+    @Test("A report with no presentation up answers nothing")+    func aReportBeforeAnyPresentationAnswersNothing() {+        var ledger = ExportOutcomeLedger()++        #expect(ledger.report(.completed, for: 0) == nil)+        #expect(ledger.dismissed() == nil)+    }++    /// The ordinary order: the activity reports while the sheet is up, and its+    /// report is the answer. A second one is not — the handler is called once,+    /// but a delivery per presentation is what the caller is promised.+    @Test("A report during the presentation is delivered once")+    func aReportDuringThePresentationIsDeliveredOnce() {+        var ledger = ExportOutcomeLedger()+        let presentation = ledger.present()++        #expect(ledger.report(.completed, for: presentation) == .completed)+        #expect(ledger.report(.completed, for: presentation) == nil)+        #expect(ledger.dismissed() == nil, "an answered presentation has nothing to wait for")+    }++    /// T-2118, the defect the owner met on the phone: with Save to Files the+    /// handler runs *after* the sheet's `onDismiss`, so a dismissal that+    /// answered on its own reported a hand-off that really happened as a+    /// cancellation, and the library was never emptied. The dismissal waits+    /// instead, and the late report is still this presentation's.+    @Test("A report that arrives after the dismissal still answers its own presentation")+    func aLateReportAnswersItsOwnPresentation() {+        var ledger = ExportOutcomeLedger()+        let presentation = ledger.present()++        #expect(ledger.dismissed() == presentation, "the dismissal waits rather than answering")+        #expect(ledger.report(.completed, for: presentation) == .completed)+        #expect(+            ledger.graceExpired(for: presentation) == nil,+            "the report won the race, so the timeout answers nothing")+    }++    /// The swipe-away, which reports nothing at all on some presentations: the+    /// grace period running out is what makes it a cancel, once.+    @Test("A dismissal with nothing reported is cancelled when the grace period ends")+    func aSilentDismissalIsCancelledByTheGracePeriod() {+        var ledger = ExportOutcomeLedger()+        let presentation = ledger.present()++        #expect(ledger.dismissed() == presentation)+        #expect(ledger.graceExpired(for: presentation) == .cancelled)+        #expect(ledger.graceExpired(for: presentation) == nil)+    }++    /// Q43's hazard, which the numbering closes rather than the old refusal of+    /// late reports: a `.completed` from a presentation that is over must not+    /// answer the next one, which was swiped away and is a cancel.+    @Test("A late report never answers the next presentation")+    func aLateReportNeverAnswersTheNextPresentation() {+        var ledger = ExportOutcomeLedger()+        let first = ledger.present()+        #expect(ledger.dismissed() == first)+        #expect(ledger.graceExpired(for: first) == .cancelled)++        let second = ledger.present()+        #expect(second != first)++        // The first sheet's activity controller, late.+        #expect(ledger.report(.completed, for: first) == nil)++        #expect(ledger.dismissed() == second)+        #expect(ledger.graceExpired(for: second) == .cancelled)+    }+}
Asterism/AsterismTests/EmptyLibraryModelTests.swift Added +555 / -0
diff --git a/Asterism/AsterismTests/EmptyLibraryModelTests.swift b/Asterism/AsterismTests/EmptyLibraryModelTests.swiftnew file mode 100644index 00000000..4dd40fa1--- /dev/null+++ b/Asterism/AsterismTests/EmptyLibraryModelTests.swift@@ -0,0 +1,555 @@+import AsterismCore+import Foundation+import Testing+@testable import Asterism++/// The Empty Library row's state machine (`empty-library` Reqs 1.3, 2.2–2.4,+/// 3.10, 4.1, 4.2).+///+/// Everything the row does without a library behind it: the counts the dialog is+/// built from, the refusal that stops the whole thing before a row is deleted,+/// the two orders the sharing surface's callbacks can arrive in (Q34), and the+/// three sentences an outcome is reported with. The pass itself is+/// `EmptyLibraryTests`' subject; what is pinned here is that nothing is deleted+/// without a hand-off and that the staged archive outlives the pass (Q28).+@Suite("Empty library model")+@MainActor+struct EmptyLibraryModelTests {++    // MARK: - The dialog (Reqs 1.2, 1.3)++    @Test("Counting the library offers the confirmation, and cancelling exports nothing")+    func cancellingTheConfirmationDoesNothing() async {+        let harness = Harness()+        harness.script.inventory = EmptyLibraryInventory(+            entries: 12, works: 3, sites: 2, waitingCaptures: 1, setAsideCaptures: 0)++        await harness.model.prepareConfirmation()+        #expect(harness.model.state == .confirming(harness.script.inventory))+        #expect(harness.model.pendingConfirmation == harness.script.inventory)++        harness.model.cancelConfirmation()++        #expect(harness.model.state == .idle)+        #expect(harness.model.pendingConfirmation == nil)+        #expect(harness.exporter.exportCallCount == 0)+        #expect(harness.script.emptyCallCount == 0)+    }++    /// Req 1.2's counts, all five of them, in the sentence the dialog shows.+    @Test("The confirmation names the rows, the captures and the every-device consequence")+    func theConfirmationMessageNamesWhatGoes() {+        let message = EmptyLibraryModel.confirmationMessage(+            EmptyLibraryInventory(+                entries: 12, works: 3, sites: 2, waitingCaptures: 1, setAsideCaptures: 4))++        #expect(message.contains("12 entries"))+        #expect(message.contains("3 works"))+        #expect(message.contains("2 sites"))+        #expect(message.contains("1 waiting"))+        #expect(message.contains("4 set-aside"))+        #expect(message.contains("device"))+        #expect(message.contains("backup"))+    }++    /// One of everything reads as one of everything, and a library with nothing+    /// preserved does not claim that zero captures are discarded.+    @Test("The counts are worded singly, and an empty spool says so")+    func theConfirmationMessageIsWordedForOne() {+        let message = EmptyLibraryModel.confirmationMessage(+            EmptyLibraryInventory(+                entries: 1, works: 1, sites: 1, waitingCaptures: 0, setAsideCaptures: 0))++        #expect(message.contains("1 entry, 1 work and 1 site"))+        #expect(!message.contains("0 "))+    }++    /// The count is a repository read, and a read can fail. Nothing is exported.+    @Test("A failed count reaches failed without exporting anything")+    func aFailedCountReachesFailed() async {+        let harness = Harness()+        harness.script.inventoryError = LibraryRepositoryError.libraryBusy(operation: "counting")++        await harness.model.prepareConfirmation()++        #expect(harness.failureMessage != nil)+        #expect(harness.exporter.exportCallCount == 0)+    }++    /// SwiftUI runs a confirmation dialog's dismissal **before** the tapped+    /// button's action, so by the time the destructive button's closure runs the+    /// binding's `set` has already cancelled the confirmation. A `confirm` that+    /// insisted on `.confirming` therefore did nothing at all when the row was+    /// driven through the real dialog, which is how the UI journey found it.+    @Test("Confirming still exports when the dialog's dismissal got there first")+    func confirmSurvivesTheDialogsOwnDismissal() async {+        let harness = Harness()+        harness.stageAFile()+        await harness.model.prepareConfirmation()++        harness.model.cancelConfirmation()+        await harness.model.confirm(harness.script.inventory)++        #expect(harness.model.state == .sharing)+        #expect(harness.exporter.exportCallCount == 1)+    }++    /// Tolerating `.idle` is not the same as accepting anything that arrives in+    /// it: the dialog's `presenting:` value is the token, and a `confirm` with+    /// nothing armed behind it is not the dialog's button.+    @Test("Confirming without a dialog behind it exports nothing")+    func confirmWithNothingArmedExportsNothing() async {+        let harness = Harness()+        harness.stageAFile()++        await harness.model.confirm(harness.script.inventory)++        #expect(harness.model.state == .idle)+        #expect(harness.exporter.exportCallCount == 0)+        #expect(harness.script.emptyCallCount == 0)+    }++    /// And the token is one-shot: one armed dialog exports once, however many+    /// times its value comes back.+    @Test("A second confirm on a spent token does nothing")+    func aSpentTokenConfirmsNothing() async {+        let harness = Harness()+        harness.stageAFile()+        await harness.confirm()+        #expect(harness.exporter.exportCallCount == 1)++        // Back to idle without a new dialog, which is what a cancelled share+        // leaves behind.+        harness.model.handleShareOutcome(.cancelled)+        #expect(harness.model.state == .idle)++        await harness.model.confirm(harness.script.inventory)++        #expect(harness.model.state == .idle)+        #expect(harness.exporter.exportCallCount == 1)+    }++    /// Req 4.1 over the window the state machine cannot see: the row is still+    /// `.idle` while the count runs, so the second tap has to be refused by the+    /// in-flight guard or it starts a second read.+    @Test("A second tap while the count is running is ignored")+    func aSecondTapDuringTheCountIsIgnored() async {+        let harness = Harness()+        let gate = PassGate()+        harness.script.inventoryHold = gate++        let counting = Task { await harness.model.prepareConfirmation() }+        await harness.waitUntil("the count to start") { harness.script.inventoryCallCount == 1 }++        await harness.model.prepareConfirmation()+        #expect(harness.script.inventoryCallCount == 1, "the second tap starts no second count")++        await gate.open()+        await counting.value+        #expect(harness.model.state == .confirming(harness.script.inventory))+        #expect(harness.script.inventoryCallCount == 1)+    }++    // MARK: - The export (Req 2.2)++    /// Req 2.2: a refused export deletes nothing and shows the refusal as the+    /// Export Backup row shows it, Check Library route included.+    @Test("A torn-groups refusal reaches failed with the Check Library route")+    func aTornRefusalRoutesToCheckLibrary() async {+        let harness = Harness()+        harness.exporter.exportResult = .failure(+            BackupV14ExportError.tornGroups(TornGroupsPayload(count: 3, blockingWorkSet: nil)))++        await harness.confirm()++        guard case .failed(let message, let routes) = harness.model.state else {+            Issue.record("Expected a refused export to fail the row, got \(harness.model.state)")+            return+        }+        #expect(message.contains("Check Library"))+        #expect(routes)+        #expect(harness.script.emptyCallCount == 0)+    }++    @Test("Every other refusal fails the row without offering a route")+    func anUnroutedRefusalOffersNoRoute() async {+        let harness = Harness()+        harness.exporter.exportResult = .failure(+            BackupV14ExportError.referencesStillArriving(detail: "rule 1"))++        await harness.confirm()++        guard case .failed(_, let routes) = harness.model.state else {+            Issue.record("Expected a refused export to fail the row, got \(harness.model.state)")+            return+        }+        #expect(!routes)+        #expect(harness.script.emptyCallCount == 0)+    }++    // MARK: - The sharing surface (Reqs 2.3, 2.4, Q34)++    /// Req 2.3: a dismissal that reports cancellation deletes nothing, removes+    /// the staged archive as the Export Backup row does, and returns to idle.+    @Test("A cancelled share deletes nothing and removes the staged archive")+    func aCancelledShareReturnsToIdle() async {+        let harness = Harness()+        let staged = harness.stageAFile()++        await harness.confirm()+        #expect(harness.model.state == .sharing)++        harness.model.shareDismissed()+        harness.model.handleShareOutcome(.cancelled)++        #expect(harness.model.state == .idle)+        #expect(harness.script.emptyCallCount == 0)+        #expect(!FileManager.default.fileExists(atPath: staged.path))+    }++    /// Q34's first order, and T-2118's defect: iOS writes the presentation+    /// binding before it delivers the outcome, and for Save to Files the+    /// activity's report arrives seconds after that dismissal. The row waits in+    /// `.shareDismissed` — with a sentence, and refusing a second run (Req 4.1)+    /// — and the late completion empties the library exactly once.+    @Test("Dismissal then outcome reaches emptying exactly once")+    func dismissalThenOutcomeEmptiesOnce() async {+        let harness = Harness()+        harness.stageAFile()+        await harness.confirm()++        harness.model.shareDismissed()+        #expect(harness.model.state == .shareDismissed)+        #expect(harness.model.progressSentence == "Waiting for the backup to be saved…")+        await harness.model.prepareConfirmation()+        #expect(harness.model.state == .shareDismissed, "a second tap while waiting is ignored")++        harness.model.handleShareOutcome(.completed)+        #expect(harness.model.state == .emptying, "the transition is synchronous")++        await harness.waitForTerminalState()+        #expect(harness.script.emptyCallCount == 1)+    }++    /// Q34's other order: the Mac's exporter result may precede the binding+    /// write, and the dismissal that follows must not re-enter sharing.+    @Test("Outcome then dismissal reaches emptying exactly once and never re-enters sharing")+    func outcomeThenDismissalEmptiesOnce() async {+        let harness = Harness()+        harness.stageAFile()+        await harness.confirm()++        harness.model.handleShareOutcome(.completed)+        #expect(harness.model.state == .emptying)+        harness.model.shareDismissed()+        #expect(harness.model.state == .emptying, "a late dismissal is not a second phase")++        await harness.waitForTerminalState()+        #expect(harness.script.emptyCallCount == 1)+    }++    /// Q28: "completed" means an activity ran, not that a file reached a+    /// destination, so the staged archive is the only local copy until the rows+    /// are actually gone.+    @Test("The staged archive is kept until the pass reaches a terminal state")+    func theStagedArchiveOutlivesTheShare() async {+        let harness = Harness()+        let staged = harness.stageAFile()+        let gate = PassGate()+        harness.script.hold = gate++        await harness.confirm()+        harness.model.handleShareOutcome(.completed)++        await harness.waitUntil("the pass to start") { harness.script.emptyCallCount == 1 }+        #expect(harness.model.state == .emptying)+        #expect(harness.model.stagedFileURL == staged)+        #expect(FileManager.default.fileExists(atPath: staged.path))++        await gate.open()+        await harness.waitForTerminalState()+        #expect(harness.model.stagedFileURL == nil)+        #expect(!FileManager.default.fileExists(atPath: staged.path))+    }++    // MARK: - The outcome (Reqs 3.10, 4.2)++    @Test("A completed pass reports the rows and says other devices empty when they sync")+    func aCompletedPassNamesTheRowsAndTheOtherDevices() async {+        let harness = Harness()+        harness.stageAFile()+        harness.script.report = EmptyLibraryReport(+            outcome: .completed, rowsDeleted: 412, remaining: 0, cleanup: .done)++        await harness.emptyThroughTheShare()++        let sentence = harness.finishedSentence+        #expect(sentence?.contains("412 rows") == true)+        #expect(sentence?.contains("sync") == true)+    }++    /// Req 3.7's cleanup is not the emptying: the rows did go, so the row+    /// finishes, and the sentence names what could not be removed (Req 4.2).+    @Test("A cleanup failure still finishes, and the sentence names it")+    func aCleanupFailureFinishesWithItsReason() async {+        let harness = Harness()+        harness.stageAFile()+        harness.script.report = EmptyLibraryReport(+            outcome: .completed, rowsDeleted: 9, remaining: 0,+            cleanup: .failed(reason: "2 preserved captures could not be removed"))++        await harness.emptyThroughTheShare()++        let sentence = harness.finishedSentence+        #expect(sentence?.contains("9 rows") == true)+        #expect(sentence?.contains("2 preserved captures could not be removed") == true)+    }++    /// Req 4.2's failure arm: the count survives the failure (Q29), and the+    /// remedy is running the row again.+    @Test("An interrupted pass fails with the rows removed, the phase and the remainder")+    func anInterruptedPassNamesThePhaseAndTheRemainder() async {+        let harness = Harness()+        harness.stageAFile()+        harness.script.report = EmptyLibraryReport(+            outcome: .interrupted(phase: "1 entries", reason: "the save was refused"),+            rowsDeleted: 500, remaining: 1200, cleanup: .skipped)++        await harness.emptyThroughTheShare()++        guard case .failed(let message, let routes) = harness.model.state else {+            Issue.record("Expected an interrupted pass to fail the row, got \(harness.model.state)")+            return+        }+        #expect(message.contains("500 rows"))+        // The phase is quoted, because Core's names carry their ordinal and+        // "before 1 entries failed" reads as a count of entries.+        #expect(message.contains("phase \"1 entries\" failed"))+        #expect(message.contains("the save was refused"))+        #expect(message.contains("1200 rows remain"))+        #expect(message.contains("again"))+        #expect(!routes, "an interruption is not something Check Library resolves")+    }++    /// A pass that throws threw before the first save (Q29), so nothing is gone+    /// — and the staged archive still goes, because the row has reached a+    /// terminal state.+    @Test("A throwing pass fails the row and releases the staged archive")+    func aThrowingPassFailsTheRow() async {+        let harness = Harness()+        let staged = harness.stageAFile()+        harness.script.emptyError = LibraryRepositoryError.libraryBusy(+            operation: "emptying the library")++        await harness.emptyThroughTheShare()++        #expect(harness.failureMessage != nil)+        #expect(!FileManager.default.fileExists(atPath: staged.path))+    }++    // MARK: - Re-entry (Req 4.1)++    /// The finished and failed arms re-enter through `prepareConfirmation`: a+    /// second run is the resume an interrupted pass asks for.+    @Test("A second run is offered from finished and from failed")+    func aSecondRunIsOfferedFromBothTerminalStates() async {+        let harness = Harness()+        harness.stageAFile()+        await harness.emptyThroughTheShare()+        #expect(harness.finishedSentence != nil)++        await harness.model.prepareConfirmation()+        #expect(harness.model.state == .confirming(harness.script.inventory))+        harness.model.cancelConfirmation()++        harness.exporter.exportResult = .failure(+            BackupV14ExportError.encodingFailed(reason: "encode"))+        await harness.confirm()+        #expect(harness.failureMessage != nil)++        await harness.model.prepareConfirmation()+        #expect(harness.model.state == .confirming(harness.script.inventory))+    }++    /// Req 4.1: the row does not accept a second tap while a phase is in flight.+    @Test("A second tap while the row is busy does nothing")+    func aSecondTapWhileBusyDoesNothing() async {+        let harness = Harness()+        harness.stageAFile()+        let gate = PassGate()+        harness.script.hold = gate++        await harness.confirm()+        await harness.model.prepareConfirmation()+        #expect(harness.model.state == .sharing, "sharing does not take a second tap")++        harness.model.handleShareOutcome(.completed)+        await harness.waitUntil("the pass to start") { harness.script.emptyCallCount == 1 }+        await harness.model.prepareConfirmation()+        #expect(harness.model.state == .emptying, "emptying does not take a second tap")+        // Nor does a second outcome start a second pass.+        harness.model.handleShareOutcome(.completed)++        await gate.open()+        await harness.waitForTerminalState()+        #expect(harness.script.emptyCallCount == 1)+    }+}++// MARK: - Harness++/// What the model's two injected closures answer with, and how many times the+/// pass ran. Held apart from the harness so the closures can capture it before+/// the harness itself exists.+@MainActor+private final class Script {+    var inventory = EmptyLibraryInventory(+        entries: 2, works: 1, sites: 1, waitingCaptures: 0, setAsideCaptures: 0)+    var inventoryError: (any Error)?+    var inventoryCallCount = 0+    /// Holds the count open, so a test can tap the row while it is running.+    var inventoryHold: PassGate?+    var report = EmptyLibraryReport(+        outcome: .completed, rowsDeleted: 4, remaining: 0, cleanup: .done)+    var emptyError: (any Error)?+    var emptyCallCount = 0+    /// Holds the pass open, so a test can look at the row mid-flight.+    var hold: PassGate?+}++/// The model over a recording exporter and a scripted pass, plus the waits a+/// `Task`-started pass needs.+@MainActor+private final class Harness {+    let exporter: RecordingBackupExporter+    let script: Script+    let model: EmptyLibraryModel+    private let staging: URL++    init() {+        let exporter = RecordingBackupExporter()+        let script = Script()+        let staging = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString)+        try? FileManager.default.createDirectory(at: staging, withIntermediateDirectories: true)++        self.exporter = exporter+        self.script = script+        self.staging = staging+        model = EmptyLibraryModel(+            stage: BackupExportStage(exporter: exporter),+            inventory: {+                script.inventoryCallCount += 1+                await script.inventoryHold?.wait()+                if let error = script.inventoryError { throw error }+                return script.inventory+            },+            empty: {+                script.emptyCallCount += 1+                await script.hold?.wait()+                if let error = script.emptyError { throw error }+                return script.report+            })+    }++    deinit {+        try? FileManager.default.removeItem(at: staging)+    }++    /// Stages a real file for the exporter to hand over, so the cleanup Req 2.3+    /// and Q28 are about can be observed on the file system.+    @discardableResult+    func stageAFile(named name: String = "Asterism-backup.json") -> URL {+        let url = staging.appending(path: name)+        try? Data("{}".utf8).write(to: url)+        exporter.exportResult = .success(BackupExportResult(fileURL: url))+        return url+    }++    /// Through the dialog and the export, to whatever the export reached.+    func confirm() async {+        await model.prepareConfirmation()+        await model.confirm(script.inventory)+    }++    /// The whole row, from the tap to a terminal state, with a completed share.+    func emptyThroughTheShare() async {+        await confirm()+        model.handleShareOutcome(.completed)+        await waitForTerminalState()+    }++    var finishedSentence: String? {+        guard case .finished(let sentence) = model.state else {+            Issue.record("Expected the row to finish, got \(model.state)")+            return nil+        }+        return sentence+    }++    var failureMessage: String? {+        guard case .failed(let message, _) = model.state else { return nil }+        return message+    }++    func waitForTerminalState() async {+        await waitUntil("the row to reach a terminal state") {+            switch self.model.state {+            case .idle, .finished, .failed: true+            default: false+            }+        }+    }++    /// Bounded polling: a condition that never holds fails the test rather than+    /// hanging the suite, and the sleep is what hands the actor to the pass.+    func waitUntil(+        _ what: String, timeout: TimeInterval = 5, _ condition: @MainActor () -> Bool,+        sourceLocation: SourceLocation = #_sourceLocation+    ) async {+        let deadline = Date().addingTimeInterval(timeout)+        while Date() < deadline {+            if condition() { return }+            try? await Task.sleep(for: .milliseconds(5))+        }+        Issue.record("Timed out waiting for \(what)", sourceLocation: sourceLocation)+    }+}++/// An awaitable hold, so a test can look at the row while the pass is still+/// running. Never blocks a thread.+private actor PassGate {+    private var continuation: CheckedContinuation<Void, Never>?+    private var opened = false++    func wait() async {+        if opened { return }+        await withCheckedContinuation { continuation = $0 }+    }++    func open() {+        opened = true+        continuation?.resume()+        continuation = nil+    }+}++/// Records what the stage asked of the exporter, and removes the staged file on+/// cleanup as `BackupV14Exporter` does.+private final class RecordingBackupExporter: BackupExporting, @unchecked Sendable {+    var exportCallCount = 0+    var cleanupCallCount = 0+    var exportResult: Result<BackupExportResult, Error> = .failure(+        BackupV14ExportError.snapshotFailed(reason: "not configured"))++    func export(metadata: BackupV14Metadata) async throws -> BackupExportResult {+        exportCallCount += 1+        return try exportResult.get()+    }++    func cleanup(_ result: BackupExportResult) {+        cleanupCallCount += 1+        try? FileManager.default.removeItem(at: result.fileURL)+    }++    func scavengeStaleFiles() {}+}
Asterism/AsterismTests/SettingsBackupModelTests.swift Modified +4 / -41
diff --git a/Asterism/AsterismTests/SettingsBackupModelTests.swift b/Asterism/AsterismTests/SettingsBackupModelTests.swiftindex 38290bfa..935614fc 100644--- a/Asterism/AsterismTests/SettingsBackupModelTests.swift+++ b/Asterism/AsterismTests/SettingsBackupModelTests.swift@@ -407,47 +407,10 @@ struct SettingsBackupModelTests {         #expect(!model.routesToCheckLibrary)     } -    /// The log line beside the on-screen message, which is where the title must-    /// **not** go.-    ///-    /// `startExport` logs `diagnosticCategory(for:)` at `privacy: .public`, and-    /// the refusal's `description` names its record — `Work “<title>”` for a-    /// cover, `Site <hostname>` for a site mode a newer build wrote. Logging-    /// the description would put a work the reader is reading, or a host they-    /// read it on, into a log any process on the device can read, for a failure-    /// whose category is all a maintainer needs. The field name is the-    /// category; the title belongs on screen, which-    /// `coverRefusalNamesTheWork` pins.-    @Test("The logged category for a refusal names its field, never its record")-    @MainActor func diagnosticCategoryCarriesNoReaderContent() {-        let cover = SettingsBackupModel.diagnosticCategory(-            for: BackupV14ExportError.unrepresentableValue(-                record: "Work “Actual Title”",-                field: BackupV14ExportError.thumbnailField,-                value: "17 bytes that are not a portrait cover"))--        #expect(cover == "export: unrepresentable thumbnail")-        #expect(!cover.contains("Actual Title"))-        #expect(!cover.contains("17 bytes"))--        // The same hole the cover arm would have opened has been standing on-        // the site arm since the refusal was written.-        let site = SettingsBackupModel.diagnosticCategory(-            for: BackupV14ExportError.unrepresentableValue(-                record: "Site reader.example", field: "mode", value: "hexagonal"))--        #expect(site == "export: unrepresentable mode")-        #expect(!site.contains("reader.example"))-        #expect(!site.contains("hexagonal"))--        // The refusals that name no record keep their whole description: a-        // count and a route carry nothing of the reader's.-        let torn = SettingsBackupModel.diagnosticCategory(-            for: BackupV14ExportError.tornGroups(-                TornGroupsPayload(count: 2, blockingWorkSet: nil)))-        #expect(torn.hasPrefix("export: "))-        #expect(torn.contains("2 records"))-    }+    // The refusal's logged category is the stage's, and+    // `BackupExportStageTests.diagnosticCategoryCarriesNoReaderContent` is where+    // its three arms are pinned. This row's copy was a forwarder with no+    // production caller behind it.      // MARK: - Scavenging on Init 
Asterism/AsterismTests/ShareSheetTests.swift Added +71 / -0
diff --git a/Asterism/AsterismTests/ShareSheetTests.swift b/Asterism/AsterismTests/ShareSheetTests.swiftnew file mode 100644index 00000000..c0ba7d00--- /dev/null+++ b/Asterism/AsterismTests/ShareSheetTests.swift@@ -0,0 +1,71 @@+#if os(iOS)+import Foundation+import Testing+import UIKit++@testable import Asterism++/// The iOS share sheet's one job beyond presenting a file: reporting whether an+/// activity ran, to the presentation that is actually up (Decision 6 of+/// `empty-library`).+///+/// `UIActivityViewController` cannot be driven from a test — no activity can be+/// completed without a person — so what is asserted here is the **wiring**: the+/// handler is installed, it carries the closure it was built with, and a second+/// installation replaces the first. That is the whole of what the numbering+/// rests on: `ShareSheet` re-installs on every update so the live handler is the+/// newest one, whichever order SwiftUI evaluated the sheet's content and the+/// presenter's `onChange` in.+@Suite("Share sheet outcome wiring")+@MainActor+struct ShareSheetTests {++    /// A box the escaping handler writes into, read back after it runs.+    private final class Reported {+        var outcomes: [Bool] = []+    }++    private static func controller() -> UIActivityViewController {+        UIActivityViewController(+            activityItems: [URL(fileURLWithPath: "/tmp/asterism-share-sheet-test.json")],+            applicationActivities: nil)+    }++    @Test("The installed handler reports the activity's completion flag")+    func theHandlerReportsCompletion() throws {+        let reported = Reported()+        let sheet = ShareSheet(+            fileURL: URL(fileURLWithPath: "/tmp/asterism-share-sheet-test.json"),+            onOutcome: { reported.outcomes.append($0) })+        let controller = Self.controller()++        sheet.install(into: controller)+        let handler = try #require(controller.completionWithItemsHandler)+        handler(nil, true, nil, nil)++        #expect(reported.outcomes == [true])+    }++    /// The newest installation wins, which is what makes "the report answers its+    /// own presentation" true: each `ShareSheet` carries the presentation number+    /// its closure was built with, and only the last one installed can fire.+    @Test("A second installation replaces the first, and only the newer closure fires")+    func theNewestHandlerWins() throws {+        let older = Reported()+        let newer = Reported()+        let file = URL(fileURLWithPath: "/tmp/asterism-share-sheet-test.json")+        let controller = Self.controller()++        ShareSheet(fileURL: file, onOutcome: { older.outcomes.append($0) })+            .install(into: controller)+        ShareSheet(fileURL: file, onOutcome: { newer.outcomes.append($0) })+            .install(into: controller)++        let handler = try #require(controller.completionWithItemsHandler)+        handler(nil, true, nil, nil)++        #expect(newer.outcomes == [true])+        #expect(older.outcomes.isEmpty, "the replaced closure must not answer as well")+    }+}+#endif
Asterism/AsterismUITests/EmptyLibrarySettingsUITests.swift Added +190 / -0
diff --git a/Asterism/AsterismUITests/EmptyLibrarySettingsUITests.swift b/Asterism/AsterismUITests/EmptyLibrarySettingsUITests.swiftnew file mode 100644index 00000000..ba91188d--- /dev/null+++ b/Asterism/AsterismUITests/EmptyLibrarySettingsUITests.swift@@ -0,0 +1,190 @@+import XCTest++/// `empty-library` Reqs 1.1–1.3, 2.2 and 2.3: the Empty Library row is reachable+/// through real navigation, its dialog names what goes, and both ways of+/// declining leave the library exactly as it was.+///+/// What a UI test **cannot** drive is the completed share: the simulator's+/// activity sheet reports nothing this suite can make it report, and a journey+/// that tapped an activity would be asserting the system sheet's layout rather+/// than the app's. The arm that deletes is therefore the runbook's, on a+/// `Development` install, under the device-run rule in `CLAUDE.md`.+///+/// Dismissing that sheet *is* drivable, through its own close button, which the+/// cancel arm below does — see the note there about which process that button+/// lives in.+///+/// The other half of Req 1.1 — that `Personal` shows no row at all — has no test+/// here either: that scheme carries no test bundle, so it is by inspection of+/// the `#if DEBUG` gate on the model, the row and the presentations+/// (`BackgroundExportSettingsUITests` says the same about its own row).+final class EmptyLibrarySettingsUITests: XCTestCase {+    let app = XCUIApplication()++    override func setUp() {+        continueAfterFailure = false+        XCUIDevice.shared.orientation = .portrait+        terminateAndWaitForExit(app)+    }++    override func tearDown() {+        terminateAndWaitForExit(app)+    }++    private func launch(_ scenario: String) {+        app.launchEnvironment["ASTERISM_UI_TEST_SCENARIO"] = scenario+        app.launchEnvironment["ASTERISM_UI_TEST_RUN_ID"] = UUID().uuidString+        app.launch()+    }++    /// Recent, then Settings, then the collapsed Debug disclosure the row lives+    /// at the end of.+    private func openTheRow() {+        waitFor(app.collectionViews["recent-list"], "The seeded library opens", timeout: 60)+        waitFor(app.buttons["settings-button"], "Recent carries the Settings route").tap()+        waitFor(app.anyElement("settings-view"), "Settings opens")+        expandSettingsDebug(witness: "settings-empty-library-run", in: app)+    }++    /// Leaves Settings and returns to Recent, which is where "unchanged" is+    /// asserted: the seeded library's one row is still in the list.+    private func closeSettingsAndAssertRecentIsIntact() {+        waitFor(app.buttons["settings-done-button"], "Settings can be closed").tap()+        waitFor(app.collectionViews["recent-list"], "Recent comes back")+        let rows = app.descendants(matching: .any)+            .matching(NSPredicate(format: "identifier BEGINSWITH %@", "recent-entry-"))+        XCTAssertGreaterThan(+            rows.count, 0, "Declining the emptying left the library where it was")+    }++    // MARK: - Req 1.2, 1.3: the dialog, and cancelling it++    func testTheDialogNamesTheCountsAndCancellingChangesNothing() {+        launch("seeded-characters")+        openTheRow()++        scrollUntilTappableAndTap(+            app.buttons["settings-empty-library-run"], in: app,+            "The Debug section offers Empty Library")++        waitFor(+            app.dialogButton("settings-empty-library-confirm"),+            "Req 1.2: emptying asks first")+        // The `seeded-characters` fixture is one entry, one work and one site,+        // and Req 1.2 says the dialog names those counts.+        let message = app.staticTexts.containing(+            NSPredicate(format: "label CONTAINS[c] %@", "1 entry, 1 work and 1 site")+        ).firstMatch+        XCTAssertTrue(+            message.waitForExistence(timeout: 10),+            "The dialog names the rows that will go")+        XCTAssertTrue(+            message.label.localizedCaseInsensitiveContains("device"),+            "Req 1.2: and that every device on the account is emptied — the dialog read "+                + "\"\(message.label)\"")++        declineConfirmationDialog(+            cancel: "settings-empty-library-cancel",+            dismissing: "settings-empty-library-confirm", in: app)++        // Req 1.3: nothing exported, nothing deleted, and the row is back where+        // it started.+        XCTAssertTrue(+            app.anyElement("settings-empty-library-run").waitForExistence(timeout: 10),+            "The row returns to idle")+        XCTAssertFalse(+            app.anyElement("settings-empty-library-result").exists,+            "A cancelled dialog reports nothing")+        closeSettingsAndAssertRecentIsIntact()+    }++    // MARK: - Req 2.3: the share that is dismissed without completing++    func testDismissingTheShareSheetDeletesNothing() {+        launch("seeded-characters")+        openTheRow()++        scrollUntilTappableAndTap(+            app.buttons["settings-empty-library-run"], in: app,+            "The Debug section offers Empty Library")+        waitFor(+            app.dialogButton("settings-empty-library-confirm"),+            "Req 1.2: emptying asks first"+        ).tap()++        // Req 2.1: the archive is exported and handed to the sharing surface+        // before anything is deleted. What the test can assert without depending+        // on the system sheet's own layout is that a sheet came up.+        // Two named witnesses, and no third that matches any sheet at all: a+        // bare `app.sheets.firstMatch` would pass on whatever presentation+        // happened to be up, including one this row never raised.+        let sheetAppeared = app.anyElement("settings-empty-library-share-sheet")+            .waitForExistence(timeout: 60)+            || app.otherElements["ActivityListView"].waitForExistence(timeout: 5)+        XCTAssertTrue(sheetAppeared, "Confirming exports a backup and offers it")++        // Req 2.3: a dismissal that reports nothing is a cancel.+        //+        // Through the sheet's own close button, which is hosted in a **separate+        // process**: it publishes `header.closeButton` with the label `Close`,+        // and `app.buttons["Close"]` does not resolve it. A swipe is the+        // fallback for a presentation that draws no such button — and it is only+        // a fallback, because a swipe that misses scrolls Settings back to the+        // top instead, which takes the Debug rows out of the tree.+        let close = app.descendants(matching: .button).matching(+            NSPredicate(format: "identifier == %@ OR label == %@", "header.closeButton", "Close")+        ).firstMatch+        if close.waitForExistence(timeout: 10), close.isHittable {+            close.tap()+        } else {+            app.swipeDown(velocity: .fast)+        }+        waitUntilGone(+            app.anyElement("settings-empty-library-share-sheet"),+            "The sharing surface closes", timeout: 30)++        scrollUntilPresent(+            app.anyElement("settings-empty-library-run"), in: app,+            "A dismissed share returns the row to idle")+        XCTAssertFalse(+            app.anyElement("settings-empty-library-result").exists,+            "Nothing was emptied, so nothing is reported")+        closeSettingsAndAssertRecentIsIntact()+    }++    // MARK: - Req 2.2: a refused export stops the whole thing++    /// The torn group the backup exporter refuses on, met through this row:+    /// nothing is deleted, the refusal is the one the Export Backup row shows,+    /// and Check Library is offered because that is where a torn group is+    /// resolved.+    func testARefusedExportReportsTheRefusalAndRoutesToCheckLibrary() {+        launch("seeded-tolerated-tornEntryGroup")+        openTheRow()++        scrollUntilTappableAndTap(+            app.buttons["settings-empty-library-run"], in: app,+            "The Debug section offers Empty Library")+        waitFor(+            app.dialogButton("settings-empty-library-confirm"),+            "Req 1.2: emptying asks first"+        ).tap()++        let result = app.anyElement("settings-empty-library-result")+        XCTAssertTrue(+            result.waitForExistence(timeout: 60),+            "Req 2.2: the refusal is reported on the row")+        XCTAssertTrue(+            result.label.localizedCaseInsensitiveContains("differing copies"),+            "The refusal is the one the Export Backup row shows; the row read "+                + "\"\(result.label)\"")+        XCTAssertTrue(+            scrollUntilPresent(+                app.anyElement("settings-empty-library-check-library"), in: app,+                "Req 2.2: a torn-groups refusal offers the Check Library route"),+            "Check Library is beside Try Again")+        XCTAssertFalse(+            app.anyElement("settings-empty-library-share-sheet").exists,+            "A refused export stages nothing to hand off")+    }+}
CHANGELOG.md Modified +113 / -0
diff --git a/CHANGELOG.md b/CHANGELOG.mdindex 9d9407d8..b304f634 100644--- a/CHANGELOG.md+++ b/CHANGELOG.md@@ -8,6 +8,21 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).  ### Fixed +- **Empty Library did nothing after the backup was saved (T-2118).** On a+  phone, saving the exported backup through Save to Files was followed by+  no deletion and no message. `completionWithItemsHandler` runs after the+  share sheet's dismissal there, the outcome was delivered at the+  dismissal, and the guard added against a stale completion (Q43) dropped+  every report that arrived after it, so the real `completed` was+  discarded and the row went back to idle. Presentations are now numbered:+  a report answers the presentation it was made under whenever it arrives,+  a report from an older presentation is dropped, and a dismissal that is+  never followed by a report counts as cancelled after three seconds+  (Decision 6). A notice line under `category:EmptyLibrary` says which of+  the two answered. Found before the feature merged; the completed arm+  cannot be driven from a UI test, which is why only a device run showed+  it.+ - **The advisory store-version read waits for a closing connection   instead of giving up.** Releasing a SwiftData container closes its   SQLite connection a moment later, and while that close folds the@@ -161,6 +176,104 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).  ### Added +- **Empty Library, verification (T-2118, `specs/empty-library/`, tasks+  19 and 20).** The `empty-library-m4` arm's provisional 60 s+  ceiling is replaced by 16 s, about twice its reading inside the full+  target (7.71 s) and above a solo band of 5.54–5.94 s over five runs of+  7,009 rows that a 45-fold swing in host load moved by 7%. Entries are+  71.6% of the pass, memberships 23.8% and works 4.5%, the cheapest per+  row, so the design's first risk resolves negative and no inverse beside+  `Site.entries` is detached (Q32 stands). `make test-performance-m4` is+  now 46 tests in 8 suites. `make test-core` runs with zero known issues+  and every suite of this feature green; `make test-quick SKIP_MAC=1`+  and the three `EmptyLibrarySettingsUITests` journeys pass, and a+  `Personal` simulator build contains no `EmptyLibrary` symbol. The two+  Mac builds first failed at CodeSign on a locked keychain; once it was+  unlocked `make build-mac`, `make build-mac-release` and a full+  `make test-quick` all passed with no warnings, and the `Personal` Mac+  binary holds no `EmptyLibrary` string where the `Development` one+  holds nine. Five store-metadata suites still fail in `make test-core`+  on a late store close racing a raw `Z_METADATA` read; they fail at the+  base commit too and their fix is in review separately. The numbers,+  the classified failures of the contended host and what is owed are in+  `specs/empty-library/verification-run.md`.++- **Empty Library, the Settings row (T-2118, `specs/empty-library/`,+  tasks 13–18 and 21).** `Development` builds gain an **Empty Library**+  row, last in the Settings Debug disclosure. Tapping it counts the+  library and raises the house confirmation dialog naming the entries,+  works, sites and waiting captures and the every-device consequence;+  confirming exports a backup through `BackupExportStage`, presents the+  share sheet (the save panel on the Mac), and only a positive completion+  signal starts the deletion. A cancelled or unreported dismissal removes+  the staged file and deletes nothing, and the staged file is kept until+  the pass is terminal (Q28). `EmptyLibraryModel` owns every sentence the+  row shows, pluralised per count, and the share dismissal and the share+  outcome are two synchronous, order-independent transitions that reach+  the emptying exactly once (Q34). `AppLibraryModel.emptyLibrary()`+  awaits a drain in flight, fences new drains while the pass runs (Q35),+  and afterwards re-reads the sidecar and refreshes the diagnoses, the+  drain report and the pending-capture surfaces; an interrupted emptying+  shows a notice in the Backup section at the next open. Review fixed a+  blocker before it shipped: `completionWithItemsHandler` can run after+  the sheet's dismissal, so a `completed` stored after delivery could+  have answered the next presentation and emptied the library on a+  swipe-to-cancel, reachable through the second run the residue needs+  (Q43). A device run then showed the other half of it, that dropping+  late reports dropped the real one, so presentations are numbered+  instead: a report answers the presentation it was made under whenever+  it arrives, an older one is dropped, and a dismissal with nothing+  reported is cancelled after three seconds (Decision 6, and the Fixed+  entry above). The confirmation is a one-shot+  token, the inventory the dialog presented, because SwiftUI runs the+  dialog's dismissal before the button's action and the state at the tap+  cannot carry "the reader saw the dialog" (Decision 5). Every+  `EmptyLibrary` log line is `notice` so Console shows and persists it+  (Q42), and failures reach the row as an error category, never a+  description (Q44). `Personal` has neither the row nor the pass: two+  declarations survive as unconditional names with `#if DEBUG` bodies+  returning nil (Q40), and a `Personal` simulator build is clean. The+  owner's manual arms, the completed share on the phone and the Mac, a+  second device online, the second run and the store size, are in+  `specs/empty-library/runbook.md`, and `CLAUDE.md` documents the row and+  names it as the one sanctioned exception to "no `Site` row is ever+  deleted".++- **Empty Library, Core pass (T-2118, `specs/empty-library/`, tasks+  1–12).** `LibraryRepository.emptyLibrary()` deletes every row of all 17+  entities, Site rows included, through the change-tracked per-row path so+  the deletions mirror. The whole file sits inside+  `#if DEBUG || ASTERISM_PERFORMANCE_TESTING`, so a `Personal` binary+  holds no routine that deletes every row (Q22). Seven phases run through+  one generic chunk loop under one exclusive lock, swept again until a+  sweep deletes nothing (Q31), with only `Site.entries` detached first+  (Q32). A Site's rules leave in the Site's own save rather than in a+  phase ahead of the Works: a taught Site outliving its title rule is a+  `siteTuple` diagnosis the chunk introduced, which the validating save+  strategy caught (Decision 3). An `AsterismEmpty.inProgress` sidecar is+  written before the first save, refuses the run if it cannot be written+  (Q30), and makes an interrupted emptying reportable at the next open;+  after the first save the pass reports rather than throws, a failed+  re-validation or spool re-listing included (Q29, Q38). The+  pending-capture spool and the `ExportOwed/` markers are removed by the+  listing taken at the start, so a file the extension lands mid-pass+  survives (Q13, Q24). The `EmptyLibrary` log category carries counts and+  reasons only. `EmptyLibraryTests` seeds from the golden archive (Q36)+  and covers the phases, the sweeps, interruption, the sidecar, the+  deferred re-fire, the spool and the export, empty, import round trip; a+  contract test ties the phase list to the schema's entity set. A new M4+  arm, `empty-library-m4`, measured 5.4–5.7 s for 7,009 rows on a busy+  host against a provisional 60 s ceiling; entries are 72% of it and+  memberships 24%, so no further inverse needs detaching. On the app+  side, `documentExporter` now reports a `DocumentExportOutcome`+  (completed or cancelled) from `completionWithItemsHandler` on iOS and+  the `fileExporter` result on the Mac, and the export, staging, cleanup+  and refusal wording moved out of `SettingsBackupModel` into+  `BackupExportStage` with no change to the backup row (Q33). Req 5.1's+  "no pending changes" clause was dropped as the importer's business+  (Decision 4, T-2334), and an importer divergence on `junkSuffixRule`+  found on the way is T-2335 (Q37).+ - **Catch-up mode: a `Catching up` reading status and a `Catching up`   section on Today (`specs/catch-up-mode/`).** A reader behind on a story   has no reason to wait for its release day. The reading status control
CLAUDE.md Modified +26 / -3
diff --git a/CLAUDE.md b/CLAUDE.mdindex 720ba6fa..7ad1b7a0 100644--- a/CLAUDE.md+++ b/CLAUDE.md@@ -68,7 +68,7 @@ invocations where a target exists. - `make test-quick` — unit-test bundle only (simulator), preceded by `build-mac`: a macOS compile failure fails it (Req 9.1). The Mac build is never installed or launched. `SKIP_MAC=1` drops that dependency loudly and owes a clean `make build-mac` before the push. - `make test` / `make test-ui` — full suites (simulator, iPhone); they skip the iPad-only suites by name - `make test-ui-ipad` — the wide-layout and wide-layout-accessibility suites on `IPAD_SIMULATOR` (simulator, safe)-- `make test-performance-m4` — M4 Core budgets, host only, no device, safe to run. **~21 minutes** (1,093 s of test time measured 2026-08-28, 1,120 s over 28 tests on 2026-08-30 after `character-ranking` added its own, and **1,120.6 s over 32 tests on 2026-09-05** after T-1910 added four Work-bearing preview arms — all quiet-host runs, all `EXIT=0` with the same eight known issues, so the 21 minutes still holds and the four added arms cost no measurable wall time. T-2093 took the settling pass from ~7.3 s to ~1.7 s per sample; three contended runs on 2026-09-05 measured 1,136 s, 1,237 s and 1,085 s over the same 32 tests, all `EXIT=0` with **seven** known issues. A *loaded* run of those same 32 tests took 1,380 s and breached a regression ceiling on an untouched arm; that is host contention, not a band. Add a ~190 s release build to any of them): most of it is the worst-case single-hostname consolidation in `M4ScalePerformanceTests` (5 samples, each paying its own ~40 s divert before a ~40 s measurement) and the Req 10.1 settling pass (10 samples, each re-seeding 1,350 duplicate rows plus an untimed observation pass). The V4→V5 migration measurement is **gone** — `retire-migration-chain` deleted the pass it timed along with the suite. **The target exits 0 on a quiet host**, with the accepted breaches reported as `withKnownIssue` known issues rather than failures — **ten** since `work-thumbnails` (four before `multi-site-works`, nine after it, eight after `drop-superseded-columns`, seven after T-2093, eight after `series-and-related-works`, nine after `work-creators`), and **eleven on a loaded host**, because `creator-converge-noop` is wrapped `isIntermittent` and only records when the host is busy. Three are long-standing: Req 5.5's three diagnosis re-derivations. Three are Req 5.4's capture-projection arms (0.093–0.102 s pre-V8 → 0.160–0.170 s at V9 → 0.169–0.176 s at V10 → **0.179–0.205 s at V12** → 0.158–0.164 s at V13 on a quieter host, the one on a path the reader waits on; V12's three new tables and V13's two are empty on that path and cost it nothing the run's own host variance does not explain, still well inside a 250 ms ceiling). The seventh is the **full**-tier no-op reconcile, and V9 recovered most of it: 1.07 s → **0.0296–0.0302 s** once `V8PopulationPass` was deleted with the columns and `MembershipReconciler.heal` was gated on the diagnosis, which is still 3.0× a 10 ms ceiling drawn before the library had a membership table; V10 left it there (0.0301 s), V12 did too (0.0308 s) and so did V13 (0.0302 s). Req 10.1's *observation* pass **retired** with that fall (2.69 s → 1.01 s, back inside its 2 s budget, and 1.02 s at V10), and its **settling** pass retired at T-2093: 88% of that pass was SwiftData maintaining `Site.entries` inside the deletion phase's one `save` (6.42 s before the fix, 0.41 s after; the measured 8.0 ms per deleted row over a 5,000-row Site is supporting evidence, not the arithmetic — only the 250 Entry losers of the 300 deleted rows sit in `Site.entries`), and detaching a chunk's doomed rows from their Site in one rewrite of that array took it from 7.3 s to ~1.6 s, inside its 2 s budget, with the 11 s floor under the known issue gone too (`specs/bugfixes/settling-pass-budget/`, Decision 32). The eighth is `series-and-related-works` Req 14.6's link dedupe, a 10 ms budget measured at 0.0100–0.0112 s over 500 links — the low end is `place-extraction`'s run at 0.010032 s, 0.3% over the budget and the closest it has come to fitting, which would turn a quiet host into a *second* way to be red: the whole-table fetch the phase opens with is 79–83% of that, so the budget sits under what SwiftData charges to materialise the rows (Q59 of that spec). The ninth is `work-creators` Req 11.6's credit dedupe, a 50 ms budget measured at 0.0591–0.0651 s over ~2,000 credits, of which the whole-table fetch is 76% (Q73 of that spec, 130 ms ceiling). The intermittent eleventh is that spec's `creator-converge-noop`, in budget at 0.0081–0.0100 s against 10 ms across five samples and never more than 19% clear of it, so it is asserted `isIntermittent` with a 20 ms ceiling (Q74). The tenth is `work-thumbnails` Q80's **`backup-export-thumbnails` peak**: the real exporter over the covered fixture writes a **51.1 MB** archive and peaks **330–462 MB** over its baseline, past the 400 MB Q61 accepted from an estimate, recorded rather than re-based, with a 600 MB regression ceiling asserted outside the block. It is deliberately **not** `isIntermittent`, which means a run whose peak lands *under* 400 MB fails the target with `Known issue was not recorded` — that fired for the first time on 2026-09-13, on a memory-pressured host at 330.2 MB, and a lower peak there is the allocator reclaiming sooner, not the export getting cheaper (`specs/work-thumbnails/verification-run.md` §2.2). Its sibling `backup-import-thumbnails` **meets** the 400 MB plainly at 63.5–159.2 MB, with a settled baseline of 103–144 MB over five runs on one host (`specs/work-thumbnails/verification-run.md` §4.1), and `thumbnail-bytes-read` (fifty on-demand cover reads, Req 7.2) is reported only, 0.032–0.231 s under the 3 s class ceiling. Its other creator arms are `credits-resolve-and-filter` at 0.0144–0.0154 s under a 20 ms budget, `dedupe-credits-fetch` at 0.0477–0.0496 s reported only, `creator-detail` at 0.0363–0.0376 s under 50 ms, and the two read arms `works-snapshot-creators` (1.75–2.11 s) and `creators-list` (0.274–0.284 s) under the 3 s class ceiling. `series-and-related-works` adds `M4SeriesScalePerformanceTests` and `work-creators` adds `M4CreatorScalePerformanceTests`, so the suite count is 7 and the test count **45** since `update-schedule` added `todayPublication` — Req 10.5's Today composition arm, one more test in `M4ToleratedScalePerformanceTests` and no new suite — on top of the 44 `work-thumbnails` reached by adding three arms and no suite — `thumbnail-bytes-read`, `backup-export-thumbnails` and `backup-import-thumbnails`, all three in `M4DuplicateScalePerformanceTests` and all three reported rather than budgeted, together worth 2.5–4 minutes of the run. It was 41 at `place-extraction`, measured as one run at **1,070 s**; the three runs made on the `work-thumbnails` branch measured **1,896 s, 2,683 s and 5,037 s** on a machine that was never quiet, so they set no band and moved none — two of them disagree with each other by 1.7× on the same arms, and an arm that never opens a store doubled (`specs/work-thumbnails/verification-run.md` §2.3). **A quiet-host run of the covered fixture is still owed**, and since `catch-up-mode` that fixture also holds 66 base works on `catchingUp`, which `WorkAuthoredContent.isBare` counts as authored, so every band below the `todayPublication` arm (0.84 s on a loaded host, `specs/catch-up-mode/verification-run.md`) is unverified against the fixture as it now stands. That arm is `place-ranking-200x50` at **0.0035 s** against a 10 ms budget and a 50 ms ceiling, recorded beside its sibling `character-ranking-200x50` at **0.0037 s** — the ranker is one generic implementation over `RecordRow` and the point of the arm is that the second conformance costs what the first does. The character arm's own number moved up from 0.0023–0.0025 s in the same change, which is the cost of `CharacterRanking` becoming `RecordRanking`; read 0.0037 s as its new resting place, not as a regression. Every one has a regression ceiling asserted *outside* its known-issue block, so a run that drifts further still fails; `RUNS=3` completes all three runs. See `specs/work-thumbnails/verification-run.md` for the current numbers and for what a contended host does to all of them, `specs/place-extraction/verification-run.md` for the last quiet-host band, `specs/work-creators/verification-run.md` and `specs/bugfixes/settling-pass-budget/` for the previous ones, `specs/series-and-related-works/verification-run.md` for the ones before those, `specs/work-and-reading-status/verification-run.md` §4 for the previous ones, `specs/drop-superseded-columns/verification-run.md` and `specs/multi-site-works/verification-run.md` §4 and §7 for the previous ones, and `docs/agent-notes/testing.md` for recording a band.+- `make test-performance-m4` — M4 Core budgets, host only, no device, safe to run. **~21 minutes** (1,093 s of test time measured 2026-08-28, 1,120 s over 28 tests on 2026-08-30 after `character-ranking` added its own, and **1,120.6 s over 32 tests on 2026-09-05** after T-1910 added four Work-bearing preview arms — all quiet-host runs, all `EXIT=0` with the same eight known issues, so the 21 minutes still holds and the four added arms cost no measurable wall time. T-2093 took the settling pass from ~7.3 s to ~1.7 s per sample; three contended runs on 2026-09-05 measured 1,136 s, 1,237 s and 1,085 s over the same 32 tests, all `EXIT=0` with **seven** known issues. A *loaded* run of those same 32 tests took 1,380 s and breached a regression ceiling on an untouched arm; that is host contention, not a band. Add a ~190 s release build to any of them): most of it is the worst-case single-hostname consolidation in `M4ScalePerformanceTests` (5 samples, each paying its own ~40 s divert before a ~40 s measurement) and the Req 10.1 settling pass (10 samples, each re-seeding 1,350 duplicate rows plus an untimed observation pass). The V4→V5 migration measurement is **gone** — `retire-migration-chain` deleted the pass it timed along with the suite. **The target exits 0 on a quiet host**, with the accepted breaches reported as `withKnownIssue` known issues rather than failures — **ten** since `work-thumbnails` (four before `multi-site-works`, nine after it, eight after `drop-superseded-columns`, seven after T-2093, eight after `series-and-related-works`, nine after `work-creators`), and **eleven on a loaded host**, because `creator-converge-noop` is wrapped `isIntermittent` and only records when the host is busy. Three are long-standing: Req 5.5's three diagnosis re-derivations. Three are Req 5.4's capture-projection arms (0.093–0.102 s pre-V8 → 0.160–0.170 s at V9 → 0.169–0.176 s at V10 → **0.179–0.205 s at V12** → 0.158–0.164 s at V13 on a quieter host, the one on a path the reader waits on; V12's three new tables and V13's two are empty on that path and cost it nothing the run's own host variance does not explain, still well inside a 250 ms ceiling). The seventh is the **full**-tier no-op reconcile, and V9 recovered most of it: 1.07 s → **0.0296–0.0302 s** once `V8PopulationPass` was deleted with the columns and `MembershipReconciler.heal` was gated on the diagnosis, which is still 3.0× a 10 ms ceiling drawn before the library had a membership table; V10 left it there (0.0301 s), V12 did too (0.0308 s) and so did V13 (0.0302 s). Req 10.1's *observation* pass **retired** with that fall (2.69 s → 1.01 s, back inside its 2 s budget, and 1.02 s at V10), and its **settling** pass retired at T-2093: 88% of that pass was SwiftData maintaining `Site.entries` inside the deletion phase's one `save` (6.42 s before the fix, 0.41 s after; the measured 8.0 ms per deleted row over a 5,000-row Site is supporting evidence, not the arithmetic — only the 250 Entry losers of the 300 deleted rows sit in `Site.entries`), and detaching a chunk's doomed rows from their Site in one rewrite of that array took it from 7.3 s to ~1.6 s, inside its 2 s budget, with the 11 s floor under the known issue gone too (`specs/bugfixes/settling-pass-budget/`, Decision 32). The eighth is `series-and-related-works` Req 14.6's link dedupe, a 10 ms budget measured at 0.0100–0.0112 s over 500 links — the low end is `place-extraction`'s run at 0.010032 s, 0.3% over the budget and the closest it has come to fitting, which would turn a quiet host into a *second* way to be red: the whole-table fetch the phase opens with is 79–83% of that, so the budget sits under what SwiftData charges to materialise the rows (Q59 of that spec). The ninth is `work-creators` Req 11.6's credit dedupe, a 50 ms budget measured at 0.0591–0.0651 s over ~2,000 credits, of which the whole-table fetch is 76% (Q73 of that spec, 130 ms ceiling). The intermittent eleventh is that spec's `creator-converge-noop`, in budget at 0.0081–0.0100 s against 10 ms across five samples and never more than 19% clear of it, so it is asserted `isIntermittent` with a 20 ms ceiling (Q74). The tenth is `work-thumbnails` Q80's **`backup-export-thumbnails` peak**: the real exporter over the covered fixture writes a **51.1 MB** archive and peaks **330–462 MB** over its baseline, past the 400 MB Q61 accepted from an estimate, recorded rather than re-based, with a 600 MB regression ceiling asserted outside the block. It is deliberately **not** `isIntermittent`, which means a run whose peak lands *under* 400 MB fails the target with `Known issue was not recorded` — that fired for the first time on 2026-09-13, on a memory-pressured host at 330.2 MB, and a lower peak there is the allocator reclaiming sooner, not the export getting cheaper (`specs/work-thumbnails/verification-run.md` §2.2). Its sibling `backup-import-thumbnails` **meets** the 400 MB plainly at 63.5–159.2 MB, with a settled baseline of 103–144 MB over five runs on one host (`specs/work-thumbnails/verification-run.md` §4.1), and `thumbnail-bytes-read` (fifty on-demand cover reads, Req 7.2) is reported only, 0.032–0.231 s under the 3 s class ceiling. Its other creator arms are `credits-resolve-and-filter` at 0.0144–0.0154 s under a 20 ms budget, `dedupe-credits-fetch` at 0.0477–0.0496 s reported only, `creator-detail` at 0.0363–0.0376 s under 50 ms, and the two read arms `works-snapshot-creators` (1.75–2.11 s) and `creators-list` (0.274–0.284 s) under the 3 s class ceiling. `series-and-related-works` adds `M4SeriesScalePerformanceTests`, `work-creators` adds `M4CreatorScalePerformanceTests` and `empty-library` adds `M4EmptyLibraryPerformanceTests` — one suite, one test, the `empty-library-m4` arm, which empties the covered fixture's **7,009 rows** three times over and costs the target ~50 s. Run **on its own** it measures **5.544–5.942 s** of median over five runs, every sample 5.541–6.244 s, and it is strikingly load-insensitive: those five spanned a one-minute load average of 5 to 241 and moved 7.2%, the loudest run *below* the second-loudest. Run **inside the target**, after twenty minutes of other suites have been through the page cache, the same arm reads **7.712 s** — which is why its regression ceiling is **16 s** (2.07× the in-target median) rather than the 12 s the solo band alone would have given; reported rather than budgeted (Q18 of that spec). `specs/empty-library/verification-run.md` has the runs, the per-phase split — entries 71.6%, memberships 23.8%, works 4.5% — and what the design's first risk turned out to be: no undetached inverse dominates, because children go before parents, so every parent's inverse array is already empty by the time the parent is deleted, and the works phase is the *cheapest* per row in the list. So the suite count is 8 and the test count **46** since `update-schedule` added `todayPublication` — Req 10.5's Today composition arm, one more test in `M4ToleratedScalePerformanceTests` and no new suite — on top of the 44 `work-thumbnails` reached by adding three arms and no suite — `thumbnail-bytes-read`, `backup-export-thumbnails` and `backup-import-thumbnails`, all three in `M4DuplicateScalePerformanceTests` and all three reported rather than budgeted, together worth 2.5–4 minutes of the run. It was 41 at `place-extraction`, measured as one run at **1,070 s**; the three runs made on the `work-thumbnails` branch measured **1,896 s, 2,683 s and 5,037 s** on a machine that was never quiet, so they set no band and moved none — two of them disagree with each other by 1.7× on the same arms, and an arm that never opens a store doubled (`specs/work-thumbnails/verification-run.md` §2.3). **A quiet-host run of the covered fixture is still owed**, and since `catch-up-mode` that fixture also holds 66 base works on `catchingUp`, which `WorkAuthoredContent.isBare` counts as authored, so every band below the `todayPublication` arm (0.84 s on a loaded host, `specs/catch-up-mode/verification-run.md`) is unverified against the fixture as it now stands. That arm is `place-ranking-200x50` at **0.0035 s** against a 10 ms budget and a 50 ms ceiling, recorded beside its sibling `character-ranking-200x50` at **0.0037 s** — the ranker is one generic implementation over `RecordRow` and the point of the arm is that the second conformance costs what the first does. The character arm's own number moved up from 0.0023–0.0025 s in the same change, which is the cost of `CharacterRanking` becoming `RecordRanking`; read 0.0037 s as its new resting place, not as a regression. Every one has a regression ceiling asserted *outside* its known-issue block, so a run that drifts further still fails; `RUNS=3` completes all three runs. See `specs/work-thumbnails/verification-run.md` for the current numbers and for what a contended host does to all of them, `specs/place-extraction/verification-run.md` for the last quiet-host band, `specs/work-creators/verification-run.md` and `specs/bugfixes/settling-pass-budget/` for the previous ones, `specs/series-and-related-works/verification-run.md` for the ones before those, `specs/work-and-reading-status/verification-run.md` §4 for the previous ones, `specs/drop-superseded-columns/verification-run.md` and `specs/multi-site-works/verification-run.md` §4 and §7 for the previous ones, and `docs/agent-notes/testing.md` for recording a band. - `make test-performance-chunks` — host-only calibration sweep of the shared bulk chunk constant (import commits and the reconciler re-pin). No device, safe to run, but gated on `ASTERISM_RUN_CHUNK_SWEEP=1` and **~20 minutes per run**, so it is deliberately *not* part of `make test-performance-m4`. It asserts nothing — a calibration is reported, not budgeted. Re-run it when the bulk write paths change (Q53 and the task 25 section of `specs/cloudkit-mirroring/implementation.md`). - `make test-performance-m4-recent` — **physical device, see above** @@ -104,6 +104,26 @@ which is a device run under the rule above: `Development` for every step, `Personal` last and only after a container download of the real library. The runbook is the owner's to run. +`Development` builds also carry an **Empty Library** row, last in that same+Debug disclosure and the only destructive one: it exports a backup, waits for+the reader to hand it off through the share sheet (the save panel on the Mac),+and only on a positive completion signal deletes every row of the live schema —+**`Site` rows and their title and URL rules included** (`specs/empty-library/`+Decision 2) — through the ordinary change-tracked per-row path, so every device+on the dev account ends up empty. A cancelled or unreported dismissal deletes+nothing. `Personal` has neither the row nor the pass behind it: the Core file is+gated `#if DEBUG || ASTERISM_PERFORMANCE_TESTING` with the fixtures (Q22), so+that binary contains no routine that deletes every row. Phases, counts and the+outcome log under `subsystem:me.nore.ig.Asterism category:EmptyLibrary` (the+share sheet's own report, for every export on iOS, is under `category:DocumentExport`), at+**notice** level, like the `BackgroundExport` lines and for the same reason: a+`.debug` line is hidden in Console by default and is never persisted, so a pass+that ran before Console was streaming would leave no evidence of what it+deleted. The interruption is `.error`. The completed-share arm on the phone+and on the Mac, a second device online during a run, and the second run that+clears the convergence residue are verified by hand from+`specs/empty-library/runbook.md`: `Development` only, and the owner's to run.+ ### Configurations are not interchangeable  | Configuration | Scheme | Bundle ID | App Group | CloudKit container | Optimization |@@ -287,8 +307,11 @@ after the set has been observed unchanged twice in one session (the settling ledger is in-memory and never persisted or synced), and only after a commit-time re-verification of the set. Never deleted: a proper subset of a same-UUID group (Decision 4 of `duplicate-reconciliation`), any `Site` row-(`cloudkit-mirroring` Decision 6), any orphan, and anything an archive lacks-on import. Never use `ModelContext.delete(model:where:)`: a store-level batch+(`cloudkit-mirroring` Decision 6 — unchanged for reconciliation, which has+exactly one sanctioned exception beside it: the `Development`-only Empty Library+pass, `empty-library` Decision 2, which is a reader action keeping nothing and+must stay the only path that deletes a `Site`), any orphan, and anything an+archive lacks on import. Never use `ModelContext.delete(model:where:)`: a store-level batch delete produces no change tracking and the deletions never reach another device. 
Makefile Modified +1 / -1
diff --git a/Makefile b/Makefileindex 863ed3df..623081a9 100644--- a/Makefile+++ b/Makefile@@ -405,7 +405,7 @@ test-performance-m4: 			--no-parallel \ 			-c release \ 			-Xswiftc -DASTERISM_PERFORMANCE_TESTING \-			--filter 'M4(ScalePerformance|ToleratedScalePerformance|ToleratedFixture|DuplicateScalePerformance|MembershipScalePerformance|SeriesScalePerformance|CreatorScalePerformance)Tests' \+			--filter 'M4(ScalePerformance|ToleratedScalePerformance|ToleratedFixture|DuplicateScalePerformance|MembershipScalePerformance|SeriesScalePerformance|CreatorScalePerformance|EmptyLibraryPerformance)Tests' \ 			|| exit $$?; \ 	done 
Packages/AsterismCore/Sources/AsterismCore/LibraryConfiguration.swift Modified +14 / -0
diff --git a/Packages/AsterismCore/Sources/AsterismCore/LibraryConfiguration.swift b/Packages/AsterismCore/Sources/AsterismCore/LibraryConfiguration.swiftindex db1cb063..bc36bd28 100644--- a/Packages/AsterismCore/Sources/AsterismCore/LibraryConfiguration.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/LibraryConfiguration.swift@@ -61,6 +61,13 @@ public struct LibraryConfiguration: Sendable, Equatable {     /// so an import that stops partway is reportable however long ago it stopped.     public static let importSidecarFilename = "AsterismImport.inProgress" +    /// `empty-library` Req 3.4. The import sidecar's counterpart, and its own+    /// file rather than a second meaning for that one (Q25): the import notice's+    /// remedy is "import the same backup again" and the emptying's is "run Empty+    /// Library again", so one file with two meanings would need a discriminator+    /// and two sentences behind one identifier.+    public static let emptySidecarFilename = "AsterismEmpty.inProgress"+     // MARK: - Pending-capture queue (pending-capture-queue Decision 7)      /// The preserved-capture area, beside the store in the App Group container.@@ -173,6 +180,13 @@ public struct LibraryConfiguration: Sendable, Equatable {         rootDirectory.appending(path: Self.importSidecarFilename)     } +    /// Written before an emptying's first save and removed once its sweeps end+    /// with zero rows, so an emptying that stops partway is reportable at the+    /// next open (`empty-library` Req 3.4).+    public var emptySidecarURL: URL {+        rootDirectory.appending(path: Self.emptySidecarFilename)+    }+     // MARK: - Pending-capture paths      public var pendingCapturesURL: URL {
Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+EmptyLibrary.swift Added +752 / -0
diff --git a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+EmptyLibrary.swift b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+EmptyLibrary.swiftnew file mode 100644index 00000000..4439be19--- /dev/null+++ b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+EmptyLibrary.swift@@ -0,0 +1,752 @@+import Foundation+import OSLog+import SwiftData++// The whole file is behind the fixture gate (Q22). The `Personal` binary then+// contains no routine that deletes every row, which is the stricter reading of+// Req 1.1; the tests build in debug and the M4 release run defines the second+// symbol, so nothing that exercises the pass loses it.+#if DEBUG || ASTERISM_PERFORMANCE_TESTING++// MARK: - What the reader is told, before and after++/// What the confirmation dialog names (Req 1.2): physical rows, not logical+/// records (Q16), and the two spool counts beside them.+public struct EmptyLibraryInventory: Sendable, Equatable {+    public let entries: Int+    public let works: Int+    public let sites: Int+    public let waitingCaptures: Int+    public let setAsideCaptures: Int++    public init(+        entries: Int, works: Int, sites: Int, waitingCaptures: Int, setAsideCaptures: Int+    ) {+        self.entries = entries+        self.works = works+        self.sites = sites+        self.waitingCaptures = waitingCaptures+        self.setAsideCaptures = setAsideCaptures+    }+}++/// How one emptying ended (Req 4.2).+///+/// **Reported, not thrown**, once the first save has happened (Q29): Req 4.2+/// needs the count on failure, and `withLockedContext` rewraps any foreign error+/// into `libraryUnavailable(reason:)`, which no count can ride out on.+public struct EmptyLibraryReport: Sendable, Equatable {+    public enum Outcome: Sendable, Equatable {+        case completed+        case interrupted(phase: String, reason: String)+    }++    /// What became of the pending-capture spool and the owed markers (Req 3.7).+    /// `.skipped` is the interrupted path: the spool is not discarded while rows+    /// remain.+    ///+    /// `.failed` carries every after-the-rows step that did not finish, the+    /// re-validation of the emptied store included (Q29): each one runs when the+    /// deletions are already committed, so none of them may cost the reader the+    /// count.+    public enum Cleanup: Sendable, Equatable {+        case done+        case failed(reason: String)+        case skipped+    }++    public let outcome: Outcome+    public let rowsDeleted: Int+    /// Non-zero only when interrupted.+    public let remaining: Int+    public let cleanup: Cleanup++    public init(outcome: Outcome, rowsDeleted: Int, remaining: Int, cleanup: Cleanup) {+        self.outcome = outcome+        self.rowsDeleted = rowsDeleted+        self.remaining = remaining+        self.cleanup = cleanup+    }++    public var isCompleted: Bool {+        if case .completed = outcome { return true }+        return false+    }+}++/// What an interrupted emptying left behind (Req 3.4).+///+/// A **report, not a resume token**, exactly as `InterruptedImportReport` is:+/// the repair is running the row again, which deletes whatever is left.+public struct InterruptedEmptyReport: Codable, Sendable, Equatable {+    public var startedAt: Date++    public init(startedAt: Date) {+        self.startedAt = startedAt+    }+}++// MARK: - The phase list++/// One entity's chunk loop, erased so a phase can hold entities of several+/// model types.+///+/// The closures are `@Sendable` because the phase list is a `static let`; they+/// capture nothing but the model type they were built for.+internal struct EmptyLibraryEntity: Sendable {+    /// The entity name, which is what the contract test compares with+    /// `AsterismSchemaV15.models` and what the log line carries.+    let name: String+    /// Deletes up to one chunk and saves it, returning how many rows went —+    /// rows the chunk's `prepare` hook took with them included. Zero means the+    /// table is empty.+    let emptyChunk: @Sendable (ModelContext, any RepositorySaveStrategy) throws -> Int+    /// How many rows the table still holds.+    let count: @Sendable (ModelContext) throws -> Int+}++/// One phase of the deletion order: children before parents, sites last (Q11).+internal struct EmptyLibraryPhase: Sendable {+    let name: String+    let entities: [EmptyLibraryEntity]+}++// MARK: - The pass++extension LibraryRepository {+    /// Req 4.3's category. Every line is `.notice` except the interruption,+    /// which is `.error`.+    ///+    /// **Not `.debug`**, which is what this shipped as: a debug line is hidden+    /// in Console by default and is never persisted, so a pass that ran before+    /// Console was streaming leaves no evidence at all — `background-export`+    /// hit exactly this, and the pass here is the destructive one.+    private static let emptyLogger = Logger(+        subsystem: "me.nore.ig.Asterism", category: "EmptyLibrary")++    /// How many sweeps of the phase list one pass may run (Q31). The lock is+    /// process-local and does not fence the mirror, so a row can arrive into an+    /// entity whose phase has already passed; three bounds the loop against a+    /// device that is receiving a stream, and the remedy for more is the second+    /// run the convergence residue already needs.+    internal static let emptyLibrarySweepLimit = 3++    /// The deletion order, and the **single place the entity set is written**+    /// (design, Deletion phases). `ModelContractTests` compares its names with+    /// `AsterismSchemaV15.models`, so a schema that gains an entity this pass+    /// does not delete fails the build rather than leaving rows behind.+    ///+    /// Children before parents. The order buys two things beyond Req 3.3: every+    /// save is bounded by the chunk size, where deleting a Site first would+    /// nullify every Entry on it in one save; and an interrupted run leaves+    /// children without parents, the shape sync already produces, rather than+    /// parents without children.+    ///+    /// **The rules go with their Site, not before it.** The design put them in+    /// their own phase ahead of the Works so that the last phase would delete+    /// bare Sites; that order breaches Req 3.3, because a `.taught` Site whose+    /// title rule has just been deleted is the illegal tuple+    /// `(taught, [], [])` — a `.siteTuple` diagnosis the chunk introduced, which+    /// is precisely what Q11 says Req 3.3 is about. Deleting the Site is what+    /// removes its rules (`.cascade` on `Site.patterns` and `Site.urlRules`,+    /// which Decision 2 already names as how the rules go), so the tuple never+    /// exists without them. The two rule entities keep their own chunk loops+    /// behind the Site: a rule whose Site is already nil is reachable no other+    /// way, and the phase list still names every entity.+    internal static let emptyLibraryPhases: [EmptyLibraryPhase] = [+        EmptyLibraryPhase(name: "1 entries", entities: [entity(Entry.self, prepare: detachEntries)]),+        EmptyLibraryPhase(name: "2 memberships", entities: [entity(WorkSiteMembership.self)]),+        EmptyLibraryPhase(+            name: "3 characters",+            // `CharacterRecord`, because the unqualified name is Swift's own+            // `Character` in every context but the schema enum's body. The+            // entity is still called `Character`, which is what the contract+            // test compares.+            entities: [entity(CharacterRecord.self), entity(CharacterSuppression.self)]),+        EmptyLibraryPhase(+            name: "4 work attachments",+            entities: [+                entity(Place.self), entity(PlaceSuppression.self), entity(WorkLink.self),+                entity(WorkCredit.self), entity(WorkDistinctPair.self),+            ]),+        EmptyLibraryPhase(name: "5 works", entities: [entity(Work.self)]),+        EmptyLibraryPhase(+            name: "6 directories",+            entities: [+                entity(Series.self), entity(Creator.self), entity(CreatorRole.self),+                entity(WorkTypeEntity.self),+            ]),+        EmptyLibraryPhase(+            name: "7 sites and rules",+            entities: [+                entity(Site.self, prepare: deleteSiteRules), entity(TitlePattern.self),+                entity(URLRulePattern.self),+            ]),+    ]++    /// The generic chunk loop, one entity at a time: `fetchLimit` rows, **no+    /// sort** — every row goes, so order within a phase is irrelevant and a sort+    /// would fault a column for nothing — a `context.delete` each, and one save+    /// through the configured strategy.+    ///+    /// Never `ModelContext.delete(model:where:)`: a store-level batch delete+    /// produces no change tracking, so the deletions would never reach another+    /// device (Req 3.2).+    /// - Parameter prepare: what the chunk does to its rows before deleting+    ///   them, returning how many **further** rows it deleted itself. Two+    ///   entities use it: `Entry` detaches its chunk from `Site.entries` and+    ///   deletes nothing extra, and `Site` deletes its own rules in the same+    ///   save.+    private static func entity<Model: PersistentModel>(+        _ type: Model.Type,+        prepare: (@Sendable ([Model], ModelContext) -> Int)? = nil+    ) -> EmptyLibraryEntity {+        EmptyLibraryEntity(+            name: String(describing: type),+            emptyChunk: { context, saveStrategy in+                var descriptor = FetchDescriptor<Model>()+                descriptor.fetchLimit = bulkOperationBatchSize+                let rows = try context.fetch(descriptor)+                guard !rows.isEmpty else { return 0 }+                let extra = prepare?(rows, context) ?? 0+                for row in rows { context.delete(row) }+                try saveStrategy.save(context)+                return rows.count + extra+            },+            count: { context in try context.fetchCount(FetchDescriptor<Model>()) })+    }++    /// Phase 1's one detachment (Q32), on the technique+    /// `specs/bugfixes/settling-pass-budget/` Decision 32 measured: SwiftData+    /// maintains `Site.entries` on every deletion and the maintenance is linear+    /// in the array — 8.0 ms per row over a 5,000-row Site — so removing the+    /// whole chunk from the array in one rewrite pays that walk once per Site+    /// per chunk instead of once per row.+    ///+    /// **What it costs, stated honestly**: the whole `Site.entries` array is+    /// rewritten once per Site per chunk, so the array work grows with the+    /// square of a Site's entry count divided by the chunk size — 10 rewrites+    /// over a 5,000-entry Site, 100 over 50,000. Accepted because the+    /// alternative, clearing the array in one save, is the unbounded save the+    /// phase order exists to avoid.+    ///+    /// Matched by object identity rather than by `id`, for the reason the+    /// reconciler's copy is: an Entry's `id` is the logical record's UUID and a+    /// duplicate group shares it, so an id set would detach rows this chunk was+    /// not asked to delete. Here it would detach rows the *next* chunk deletes,+    /// which is harmless but is not what the call says.+    ///+    /// No other inverse is detached. The rest are per-Work or per-Site arrays of+    /// tens of rows, and `SiteInverseReachTests` sanctions a traversal by+    /// measurement rather than by argument.+    private static let detachEntries: @Sendable ([Entry], ModelContext) -> Int = { entries, _ in+        let doomed = Set(entries.map(ObjectIdentifier.init))+        var sitesByIdentity: [ObjectIdentifier: Site] = [:]+        for entry in entries {+            guard let site = entry.site else { continue }+            sitesByIdentity[ObjectIdentifier(site)] = site+        }+        for site in sitesByIdentity.values {+            site.entries?.removeAll { doomed.contains(ObjectIdentifier($0)) }+        }+        return 0+    }++    /// A Site's rules go in the **same save** the Site does, which is what keeps+    /// the tuple `(mode, patterns, urlRules)` from ever being observed with the+    /// mode but not the rules (Req 3.3).+    ///+    /// Deleting them here rather than leaving them to the `.cascade` is only+    /// about the count: a cascaded row is removed without this pass ever+    /// fetching it, so Req 4.2's "rows removed" would silently exclude every+    /// rule the reader ever taught. The rule entities still have their own chunk+    /// loops behind this one, for a rule whose Site is already nil.+    private static let deleteSiteRules: @Sendable ([Site], ModelContext) -> Int = { sites, context in+        var deleted = 0+        for site in sites {+            for pattern in site.patternValues {+                context.delete(pattern)+                deleted += 1+            }+            for rule in site.urlRuleValues {+                context.delete(rule)+                deleted += 1+            }+        }+        return deleted+    }++    // MARK: Inventory++    /// The row counts the confirmation dialog names, and the spool counts beside+    /// them (Req 1.2). Informative: the deletion removes whatever the library+    /// holds when it runs.+    public func emptyLibraryInventory() async throws -> EmptyLibraryInventory {+        let counts = try await withLockedContext(+            mode: .shared, operation: "counting the library to empty"+        ) { context in+            (+                entries: try context.fetchCount(FetchDescriptor<Entry>()),+                works: try context.fetchCount(FetchDescriptor<Work>()),+                sites: try context.fetchCount(FetchDescriptor<Site>())+            )+        }+        let spool = try await emptyLibrarySpool().inventory()+        return EmptyLibraryInventory(+            entries: counts.entries, works: counts.works, sites: counts.sites,+            waitingCaptures: spool.waiting.count, setAsideCaptures: spool.setAside.count)+    }++    // MARK: The emptying++    /// Deletes every row of every entity through the ordinary change-tracked+    /// path, so the deletions mirror (Decision 1, Req 3.1, 3.2).+    ///+    /// Throws **only before anything is deleted**: the gate is not `.multiSite`,+    /// the spool listing fails, the sidecar cannot be written (Q30), or the lock+    /// cannot be taken. Everything after the first save is reported so+    /// `rowsDeleted` is never lost (Q29).+    ///+    /// Holds `bulkOperationInProgress` for its whole duration and re-fires the+    /// deferred reconcile on every exit path, `confirmImport`'s shape with Q12's+    /// amendment: a throwing empty must not leave a deferred pass stranded.+    public func emptyLibrary() async throws -> EmptyLibraryReport {+        guard capabilities.gate == .multiSite else {+            throw LibraryRepositoryError.invalidInput(+                operation: "emptying the library",+                reason: "emptying requires the multi-site capability gate, "+                    + "got \(capabilities.gate.rawValue)")+        }+        // **Refused while another bulk operation holds the flag**, before+        // anything is listed, written or deleted. `confirmImport` raises the+        // same flag and clears it in a `defer`, and so does this pass: an+        // emptying started during an import would clear the flag at *its* end+        // and un-fence the import's tail, so a reconcile trigger arriving+        // afterwards would run over a half-committed import. The row is a tap+        // the reader can make while an import is running, which is why this one+        // refuses rather than trusting its callers to serialise.+        guard !bulkOperationInProgress else {+            throw LibraryRepositoryError.libraryBusy(operation: "emptying the library")+        }++        bulkOperationInProgress = true+        defer { bulkOperationInProgress = false }+        do {+            let report = try await runEmptyLibraryPass()+            await refireDeferredReconcile()+            return report+        } catch {+            await refireDeferredReconcile()+            throw error+        }+    }++    /// The report an interrupted emptying left, or nil. Read at open beside+    /// `interruptedImport()`, **however old** — a sidecar from a month ago still+    /// means that emptying did not finish.+    public func interruptedEmpty() -> InterruptedEmptyReport? {+        Self.readEmptySidecar(at: configuration.emptySidecarURL)+    }++    private func runEmptyLibraryPass() async throws -> EmptyLibraryReport {+        let spool = emptyLibrarySpool()+        let owed = ExportOwedMarker(directory: configuration.exportOwedURL)+        // Both listings happen **before** the sidecar and the lock (Q13): the+        // cutoff is what makes Req 3.6 and Req 3.7 consistent, and a file that+        // lands after it survives.+        let spoolInventory = try await spool.inventory()+        let owedNames = try owed.pending().map(\.name)++        // A run with no record of itself is the one that must not start (Q30).+        try writeEmptySidecar(InterruptedEmptyReport(startedAt: clock.now()))++        // `.public` on all three, for the reason the per-phase line gives: OSLog+        // redacts an interpolated value by default, and these are counts, which+        // Req 4.3 asks for by name. Counts only — never a record's name.+        Self.emptyLogger.notice(+            """+            Emptying started: \(spoolInventory.waiting.count, privacy: .public) waiting, \+            \(spoolInventory.setAside.count, privacy: .public) set aside, \+            \(owedNames.count, privacy: .public) owed markers+            """)++        let sweeps: EmptySweepResult+        do {+            sweeps = try await withLockedContext(+                mode: .exclusive, operation: "emptying the library"+            ) { context in+                Self.sweep(context: context, saveStrategy: self.saveStrategy)+            }+        } catch {+            // **A throw here means no row was deleted, so the sidecar goes.**+            // Verified rather than assumed: `withLockedContext` throws from+            // `CrossProcessLibraryLock.acquire` and from `liveContainer`, both+            // of which run *before* the closure, and from the closure itself —+            // and `sweep` is declared non-throwing, because Q29 has it report a+            // failed chunk rather than throw it. There is therefore no path on+            // which a save has already committed when this runs.+            //+            // Leaving the sidecar would show the reader "Nothing was deleted"+            // from this throw and "Emptying the library did not finish. Some+            // records remain." from the sidecar at the same time — and the+            // second one across every later launch, for a pass that never+            // started.+            clearEmptySidecar()+            Self.emptyLogger.error(+                """+                Emptying did not start: \(Self.reasonWithoutContent(error), privacy: .public); \+                nothing was deleted+                """)+            throw error+        }++        if case .interrupted(let phase, let reason) = sweeps.outcome {+            // The sidecar stays and the spool is not discarded while rows+            // remain; the next run finishes the job and clears both.+            Self.emptyLogger.error(+                """+                Emptying interrupted in \(phase, privacy: .public): \+                \(reason, privacy: .public); \+                \(sweeps.remaining, privacy: .public) rows remain+                """)+            return EmptyLibraryReport(+                outcome: .interrupted(phase: phase, reason: reason),+                rowsDeleted: sweeps.rowsDeleted, remaining: sweeps.remaining, cleanup: .skipped)+        }++        // Req 3.8 by construction: an emptied store validates to nothing, for+        // every hostname, including hostnames that had Entries but no Site row.+        //+        // **Caught, not thrown** (Q29): every deletion is committed by the time+        // this runs, so a refresh that fails costs the published diagnoses —+        // which the next open re-derives from the store — and must not cost the+        // reader the count. It is reported as a cleanup failure with the rest.+        var failures: [String] = []+        do {+            let refreshed = try await withLockedContext(+                mode: .shared, operation: "validating the emptied library"+            ) { context in+                try Self.validateStore(context: context)+            }+            diagnostics = refreshed+            setQuarantine(refreshed.quarantineMap())+        } catch {+            failures.append("the emptied library could not be re-validated")+        }++        failures += await discardSpool(+            spool, inventory: spoolInventory, owed: owed, names: owedNames)+        let cleanup: EmptyLibraryReport.Cleanup =+            failures.isEmpty ? .done : .failed(reason: failures.joined(separator: "; "))+        // Cleared once the sweeps end with zero rows, whatever the cleanup+        // verdict: the emptying itself did finish, and a stale notice would send+        // the reader back for rows that are not there.+        clearEmptySidecar()++        Self.emptyLogger.notice(+            """+            Emptying deleted \(sweeps.rowsDeleted, privacy: .public) rows; \+            cleanup \(String(describing: cleanup), privacy: .public)+            """)+        return EmptyLibraryReport(+            outcome: .completed, rowsDeleted: sweeps.rowsDeleted, remaining: 0, cleanup: cleanup)+    }++    /// What the locked closure carries back out. Every member is `Sendable`+    /// because it crosses out of the context.+    private struct EmptySweepResult: Sendable {+        var outcome: EmptyLibraryReport.Outcome = .completed+        var rowsDeleted = 0+        var remaining = 0+    }++    /// Up to `emptyLibrarySweepLimit` sweeps of the phase list, until one+    /// deletes nothing (Q31).+    ///+    /// Non-throwing: a chunk whose save or fetch fails is rolled back and+    /// reported, which is what keeps `rowsDeleted` on the failure path (Q29).+    private static func sweep(+        context: ModelContext, saveStrategy: any RepositorySaveStrategy+    ) -> EmptySweepResult {+        var result = EmptySweepResult()+        for sweep in 1...emptyLibrarySweepLimit {+            var deletedThisSweep = 0+            for phase in emptyLibraryPhases {+                var deletedInPhase = 0+                for entity in phase.entities {+                    // The loop's termination is **progress, not exhaustion**.+                    // It used to end only when a fetch stopped answering, which+                    // is a property of the save having worked: a strategy that+                    // rolls back without throwing — or any future save that+                    // leaves the rows in place — spins here forever, holding+                    // the exclusive lock and the actor with it. So the table's+                    // remaining count is read after every chunk, and a chunk+                    // that saved without shrinking it is reported in the same+                    // `interrupted` shape a failed save is.+                    var remainingHere = (try? entity.count(context)) ?? .max+                    while true {+                        let deleted: Int+                        do {+                            deleted = try entity.emptyChunk(context, saveStrategy)+                        } catch {+                            // The chunk is rolled back whole. After a rollback+                            // SwiftData does not restore `@Model` properties, so+                            // nothing here reads a row afterwards.+                            context.rollback()+                            result.outcome = .interrupted(+                                phase: phase.name, reason: reasonWithoutContent(error))+                            result.remaining = (try? remainingRows(context: context)) ?? 0+                            return result+                        }+                        guard deleted > 0 else { break }+                        deletedInPhase += deleted+                        deletedThisSweep += deleted+                        result.rowsDeleted += deleted++                        // Content-free, like every other reason on this path:+                        // the entity name is a constant in this file's phase+                        // list, never a record's.+                        guard let left = try? entity.count(context), left < remainingHere else {+                            // The chunk that did not shrink the table did not+                            // delete anything either, whatever it counted.+                            result.rowsDeleted -= deleted+                            result.outcome = .interrupted(+                                phase: phase.name, reason: "no progress in \(entity.name)")+                            result.remaining = (try? remainingRows(context: context)) ?? 0+                            return result+                        }+                        remainingHere = left+                    }+                }+                // `.public` on the phase name and the count, because OSLog+                // redacts a dynamic string by default and a line reading+                // `phase <private>` says nothing. Neither is reader content:+                // Req 4.3 asks for counts and reasons, and a phase name is the+                // reason a count is what it is.+                emptyLogger.notice(+                    """+                    Sweep \(sweep, privacy: .public) \+                    phase \(phase.name, privacy: .public): \+                    \(deletedInPhase, privacy: .public) rows deleted+                    """)+            }+            guard deletedThisSweep > 0 else { break }+        }++        let remaining = (try? remainingRows(context: context)) ?? 0+        if remaining > 0 {+            // A third sweep that still finds rows is reported, not looped: the+            // remedy is the second run the residue already needs (Q31).+            result.outcome = .interrupted(+                phase: "sweep",+                reason: "\(emptyLibrarySweepLimit) sweeps left \(remaining) rows behind")+            result.remaining = remaining+        }+        return result+    }++    /// An error named by its domain and code, never by its message.+    ///+    /// The reason reaches the reader's row (Req 4.2) and is logged, and Req 4.3+    /// allows counts and reasons — not content. A save error's description names+    /// the row it failed on, which on this pass is a record the reader wrote, so+    /// the description itself is the thing that must not travel. The domain and+    /// the code say which failure it was without saying what it was about.+    private static func reasonWithoutContent(_ error: any Error) -> String {+        let bridged = error as NSError+        return "\(bridged.domain) code \(bridged.code)"+    }++    private static func remainingRows(context: ModelContext) throws -> Int {+        try emptyLibraryPhases.reduce(0) { total, phase in+            try phase.entities.reduce(total) { $0 + (try $1.count(context)) }+        }+    }++    // MARK: The spool and the owed markers++    /// The pass constructs both from its own configuration (Q27): the listing+    /// must precede the lock and the removal must follow the sweeps, inside one+    /// call whose report carries the cleanup verdict.+    private func emptyLibrarySpool() -> PendingCaptureSpool {+        PendingCaptureSpool(rootDirectory: configuration.rootDirectory, clock: clock)+    }++    /// Removes what the listings named, and reads the verdict back off a+    /// re-listing — `ExportOwedMarker.clear` reports nothing, and `discard` is+    /// deliberately quiet about a file the drain or the reader removed in+    /// between.+    ///+    /// Answers with the reasons it failed, empty on success: the caller joins+    /// them with whatever else failed after the rows were already gone.+    ///+    /// The owed markers are cleared whether or not the spool's re-listing+    /// worked. They live in their own directory and their own verdict; one+    /// unreadable directory is no reason to leave the other behind.+    private func discardSpool(+        _ spool: PendingCaptureSpool,+        inventory: PendingCaptureInventory,+        owed: ExportOwedMarker,+        names: [String]+    ) async -> [String] {+        var failures: [String] = []+        do {+            let leftBehind = try await spool.discard(inventory)+            if !leftBehind.isEmpty {+                failures.append("\(leftBehind.count) preserved captures could not be removed")+            }+        } catch {+            failures.append("the preserved-capture area could not be re-listed")+        }++        owed.clear(names)+        let listed = Set(names)+        let markersLeft = ((try? owed.pending()) ?? []).filter { listed.contains($0.name) }+        if !markersLeft.isEmpty {+            failures.append("\(markersLeft.count) export markers could not be removed")+        }+        return failures+    }++    // MARK: The sidecar++    /// Written before the first save and after the listings; a write failure+    /// **throws** (Q30). `confirmImport` ignores its own sidecar's failure;+    /// here the sidecar is the only record of a destructive interruption.+    internal func writeEmptySidecar(_ report: InterruptedEmptyReport) throws {+        do {+            let data = try JSONEncoder().encode(report)+            try FileManager.default.createDirectory(+                at: configuration.rootDirectory, withIntermediateDirectories: true)+            try data.write(to: configuration.emptySidecarURL, options: .atomic)+        } catch {+            throw LibraryRepositoryError.libraryUnavailable(+                operation: "recording that an emptying started",+                reason: String(describing: error))+        }+    }++    internal func clearEmptySidecar() {+        try? FileManager.default.removeItem(at: configuration.emptySidecarURL)+    }++    /// A corrupt or unreadable sidecar is still evidence that an emptying+    /// started, so it reports rather than vanishing — exactly as+    /// `readImportSidecar` does.+    internal static func readEmptySidecar(at url: URL) -> InterruptedEmptyReport? {+        guard let data = try? Data(contentsOf: url) else { return nil }+        if let report = try? JSONDecoder().decode(InterruptedEmptyReport.self, from: data) {+            return report+        }+        return InterruptedEmptyReport(startedAt: .distantPast)+    }+}++// MARK: - The preserved-capture area, as one listing and one discard++/// What the spool held when the emptying began (Q13).+///+/// Names rather than records: the cutoff has to be a listing the discard can act+/// on by path, and reading the bodies would quarantine an unreadable file as a+/// side effect of counting it.+public struct PendingCaptureInventory: Sendable, Equatable {+    let waiting: [UUID]+    let setAside: [UUID]+    let refusals: [UUID]+    let incomingNames: [String]+    let hasReport: Bool++    init(+        waiting: [UUID] = [], setAside: [UUID] = [], refusals: [UUID] = [],+        incomingNames: [String] = [], hasReport: Bool = false+    ) {+        self.waiting = waiting+        self.setAside = setAside+        self.refusals = refusals+        self.incomingNames = incomingNames+        self.hasReport = hasReport+    }++    public var isEmpty: Bool {+        waiting.isEmpty && setAside.isEmpty && refusals.isEmpty && incomingNames.isEmpty+            && !hasReport+    }++    /// Everything named here, as a count — what the cleanup verdict reports.+    public var count: Int {+        waiting.count + setAside.count + refusals.count + incomingNames.count + (hasReport ? 1 : 0)+    }+}++extension PendingCaptureSpool {++    /// Everything the area holds right now, by name.+    ///+    /// Deliberately **not** `enumerate()`: that pass moves an unreadable file+    /// into `quarantine/` as it goes (Q42), which would make a listing taken for+    /// a cutoff mutate the thing it is listing.+    public func inventory() throws -> PendingCaptureInventory {+        /// Sorted by file name, so a listing is the same listing twice.+        func listing(in directory: URL) throws -> [URL] {+            try files(in: directory).sorted { $0.lastPathComponent < $1.lastPathComponent }+        }+        /// **`identity(ofFileAt:)`, not any name that parses as a UUID.**+        /// `discard` removes `<uuid>.json` and nothing else, so a looser reading+        /// here counted a `<uuid>.txt` in the dialog, left it on disk, and then+        /// reported it as a cleanup that failed.+        func identities(in directory: URL) throws -> [UUID] {+            try listing(in: directory).compactMap(PendingCaptureSpool.identity(ofFileAt:))+        }+        func names(in directory: URL) throws -> [String] {+            try listing(in: directory).map(\.lastPathComponent)+        }+        return PendingCaptureInventory(+            waiting: try identities(in: paths.pendingCapturesPendingURL),+            setAside: try identities(in: paths.pendingCapturesQuarantineURL),+            refusals: try identities(in: paths.pendingCapturesRefusalsURL),+            incomingNames: try names(in: paths.pendingCapturesIncomingURL),+            hasReport: FileManager.default.fileExists(+                atPath: paths.pendingCaptureReportURL.path))+    }++    /// Removes exactly what `inventory` named, and answers with what is still+    /// there — empty on success.+    ///+    /// Quiet about a missing file: a record the drain or the reader removed in+    /// between is the normal outcome of two passes racing. `incoming/` names+    /// are unlinked one by one and nothing else in that directory is touched+    /// (Q24) — a real spool write is a durable write into `incoming/` then an+    /// atomic rename out, milliseconds apart, so deleting the directory would+    /// race a write in flight.+    ///+    /// **Throws when the re-listing does.** A directory this pass cannot read —+    /// a mode that changed under it, or EPERM under file protection on a locked+    /// screen — says nothing about what is still in it, and an empty answer off+    /// a failed listing would report a spool that was emptied when it may be+    /// untouched.+    public func discard(_ listing: PendingCaptureInventory) throws -> PendingCaptureInventory {+        for id in listing.waiting { try? delete(id: id) }+        for id in listing.setAside { try? deleteQuarantined(id: id) }+        for id in listing.refusals { try? deleteRefusal(id: id) }+        if listing.hasReport {+            try? FileManager.default.removeItem(at: paths.pendingCaptureReportURL)+        }+        for name in listing.incomingNames {+            try? FileManager.default.removeItem(+                at: paths.pendingCapturesIncomingURL.appending(path: name))+        }++        let present = try inventory()+        guard !present.isEmpty else { return PendingCaptureInventory() }+        // Only what this pass was asked to remove: a file that landed after the+        // listing survives and is not the emptying's failure (Req 3.7).+        return PendingCaptureInventory(+            waiting: present.waiting.filter(Set(listing.waiting).contains),+            setAside: present.setAside.filter(Set(listing.setAside).contains),+            refusals: present.refusals.filter(Set(listing.refusals).contains),+            incomingNames: present.incomingNames.filter(Set(listing.incomingNames).contains),+            hasReport: listing.hasReport && present.hasReport)+    }+}++#endif
Packages/AsterismCore/Sources/AsterismCore/PendingCaptureSpool.swift Modified +12 / -2
diff --git a/Packages/AsterismCore/Sources/AsterismCore/PendingCaptureSpool.swift b/Packages/AsterismCore/Sources/AsterismCore/PendingCaptureSpool.swiftindex d60bdac7..c8791945 100644--- a/Packages/AsterismCore/Sources/AsterismCore/PendingCaptureSpool.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/PendingCaptureSpool.swift@@ -338,7 +338,11 @@ public enum PendingCaptureSpoolError: Error, Equatable, Sendable, CustomStringCo /// rename, so concurrent extension instances cannot collide, and the byte bound /// is advisory rather than exact (Decision 9). public actor PendingCaptureSpool {-    private let paths: LibraryConfiguration+    /// `internal` rather than `private` so the empty-library listing and discard+    /// — an extension in `LibraryRepository+EmptyLibrary.swift` — address the+    /// same four directories this actor does rather than re-deriving them from a+    /// second `LibraryConfiguration`.+    internal let paths: LibraryConfiguration     private let clock: any RepositoryClock      /// `rootDirectory` is the App Group container — the same root the library@@ -956,7 +960,13 @@ public actor PendingCaptureSpool {         throw Self.failure(operation: "removing \(url.lastPathComponent)")     } -    private func files(in directory: URL) throws -> [URL] {+    /// Regular-file URLs in a directory, a missing directory included as empty.+    ///+    /// `internal` rather than private because the `empty-library` inventory+    /// lists the same four directories and had grown its own copy of this walk+    /// (`LibraryRepository+EmptyLibrary.swift`), which is one listing rule in+    /// two places — as `paths` already is for the same reason.+    internal func files(in directory: URL) throws -> [URL] {         guard FileManager.default.fileExists(atPath: directory.path) else { return [] }         do {             return try FileManager.default.contentsOfDirectory(
Packages/AsterismCore/Tests/AsterismCoreTests/EmptyLibraryTests.swift Added +994 / -0
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/EmptyLibraryTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/EmptyLibraryTests.swiftnew file mode 100644index 00000000..9bb85cdb--- /dev/null+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/EmptyLibraryTests.swift@@ -0,0 +1,994 @@+import Foundation+import SwiftData+import Testing++@testable import AsterismCore++/// The deletion contract of `empty-library`, over a small seeded store.+///+/// The seed is the **golden archive**, imported through the real commit path:+/// it is the one fixture in the package that populates every array a 14/15+/// payload can hold, which is the same thing as populating every entity the live+/// schema declares. A pass asserted over a store missing half the tables would+/// say nothing about Req 3.1, and the coverage test in `ModelContractTests` only+/// pins the *list* — this pins what the list does.+@Suite("Emptying the library", .serialized)+struct EmptyLibraryTests {++    // MARK: - Req 3.1, 3.2: everything goes++    @Test("Every entity reaches zero rows, and a second run has nothing to do")+    func everyEntityReachesZero() async throws {+        let fixture = try await EmptyLibraryFixture()+        try await fixture.seedGoldenLibrary()++        let before = try await fixture.repository.emptyLibraryRowCounts()+        #expect(before.count == 17, "the phase list must name every entity")+        #expect(+            before.values.allSatisfy { $0 > 0 },+            "the seed leaves \(before.filter { $0.value == 0 }.keys.sorted()) empty")++        let report = try await fixture.repository.emptyLibrary()++        #expect(report.outcome == .completed)+        #expect(report.remaining == 0)+        #expect(report.cleanup == .done)+        #expect(report.rowsDeleted == before.values.reduce(0, +))++        let after = try await fixture.repository.emptyLibraryRowCounts()+        #expect(+            after.values.allSatisfy { $0 == 0 },+            "\(after.filter { $0.value > 0 }) survived the emptying")++        // Idempotent: a run over an empty library deletes nothing and says so.+        let second = try await fixture.repository.emptyLibrary()+        #expect(second.outcome == .completed)+        #expect(second.rowsDeleted == 0)+        await fixture.tearDown()+    }++    // MARK: - Req 3.3: every chunk boundary validates++    @Test("No chunk boundary introduces a tuple diagnosis")+    func everyChunkBoundaryValidates() async throws {+        let strategy = ValidatingSaveStrategy()+        let fixture = try await EmptyLibraryFixture(saveStrategy: strategy)+        try await fixture.seedGoldenLibrary()++        strategy.arm(baseline: try await fixture.repository.tupleDiagnosesForTesting())+        let report = try await fixture.repository.emptyLibrary()++        #expect(report.outcome == .completed)+        #expect(strategy.savesValidated > 0, "the pass must have saved something to validate")+        #expect(+            strategy.introduced.isEmpty,+            "chunks introduced diagnoses for \(strategy.introduced)")+        await fixture.tearDown()+    }++    // MARK: - Q31: the sweeps++    @Test("A row arriving while the last phase saves is taken by the second sweep")+    func aRowArrivingMidPassIsSweptUp() async throws {+        let strategy = LateArrivalSaveStrategy()+        let fixture = try await EmptyLibraryFixture(saveStrategy: strategy)+        try await fixture.seedGoldenLibrary()++        strategy.arm()+        let report = try await fixture.repository.emptyLibrary()++        #expect(strategy.didInsert, "the fixture never reached the phase it arms")+        #expect(report.outcome == .completed)+        #expect(report.remaining == 0)+        let after = try await fixture.repository.emptyLibraryRowCounts()+        #expect(after.values.allSatisfy { $0 == 0 })+        await fixture.tearDown()+    }++    // MARK: - Req 3.8: the diagnoses go with the rows++    @Test("An emptied library publishes no diagnosis and quarantines nothing")+    func emptyingClearsTheDiagnoses() async throws {+        let fixture = try await EmptyLibraryFixture()+        // `.invalidSiteTuple` is produced only by the full validation the open+        // runs, so the library has to be reopened over the seeded rows before it+        // carries the diagnosis this test is about.+        try await fixture.repository.seedToleratedStateFixture(.invalidSiteTuple)+        try await fixture.reopen()+        #expect(await !fixture.repository.diagnostics.isEmpty)+        #expect(await !fixture.repository.quarantined.isEmpty)++        let report = try await fixture.repository.emptyLibrary()++        #expect(report.outcome == .completed)+        #expect(await fixture.repository.diagnostics.isEmpty)+        #expect(await fixture.repository.quarantined.isEmpty)+        await fixture.tearDown()+    }++    // MARK: - Req 3.4: interruption++    @Test("A chunk that fails to save reports what it deleted and leaves a sidecar")+    func anInterruptedRunReportsAndLeavesASidecar() async throws {+        let strategy = FailAfterSaveStrategy()+        let fixture = try await EmptyLibraryFixture(saveStrategy: strategy)+        try await fixture.seedGoldenLibrary()++        let before = try await fixture.repository.emptyLibraryRowCounts()+        // The seed is well under one chunk, so each entity is emptied by exactly+        // one save: letting the first through and refusing the rest stops the+        // pass with the Entry table gone and everything else still there.+        strategy.failAfter = 1+        let report = try await fixture.repository.emptyLibrary()++        guard case .interrupted(let phase, _) = report.outcome else {+            Issue.record("expected an interrupted report, got \(report.outcome)")+            return+        }+        #expect(phase == "2 memberships")+        #expect(report.rowsDeleted == before["Entry"])+        #expect(report.remaining == before.values.reduce(0, +) - (before["Entry"] ?? 0))+        #expect(report.cleanup == .skipped)++        // The library is still openable, and the sidecar says the emptying did+        // not finish however long ago it stopped.+        strategy.failAfter = nil+        try await fixture.reopen(saveStrategy: strategy)+        #expect(await fixture.repository.interruptedEmpty() != nil)++        let second = try await fixture.repository.emptyLibrary()+        #expect(second.outcome == .completed)+        let after = try await fixture.repository.emptyLibraryRowCounts()+        #expect(after.values.allSatisfy { $0 == 0 })+        #expect(await fixture.repository.interruptedEmpty() == nil)+        #expect(!FileManager.default.fileExists(atPath: fixture.configuration.emptySidecarURL.path))+        await fixture.tearDown()+    }++    /// Q30: `confirmImport` ignores its own sidecar's write failure; here the+    /// sidecar is the only record of a destructive interruption, so a run with+    /// no record of itself must not start.+    @Test("A sidecar that cannot be written refuses the run before anything is deleted")+    func anUnwritableSidecarRefusesTheRun() async throws {+        let fixture = try await EmptyLibraryFixture()+        try await fixture.seedGoldenLibrary()+        let before = try await fixture.repository.emptyLibraryRowCounts()++        // A directory where the sidecar's file belongs: the atomic write fails+        // and nothing else about the library does.+        try FileManager.default.createDirectory(+            at: fixture.configuration.emptySidecarURL, withIntermediateDirectories: true)++        await #expect(throws: LibraryRepositoryError.self) {+            _ = try await fixture.repository.emptyLibrary()+        }+        #expect(try await fixture.repository.emptyLibraryRowCounts() == before)+        await fixture.tearDown()+    }++    /// The other half of Q30: a run that wrote a sidecar for itself and then+    /// could not take the lock deleted **nothing**, so the sidecar must not+    /// survive it.+    ///+    /// Without this the reader was told two things at once — "Nothing was+    /// deleted" from the throw and "Emptying the library did not finish. Some+    /// records remain." from the sidecar — and the second one at every later+    /// launch, for a pass that never started.+    @Test("A run that cannot take the lock leaves no interrupted sidecar")+    func aRefusedLockClearsItsOwnSidecar() async throws {+        let fixture = try await EmptyLibraryFixture()+        try await fixture.seedGoldenLibrary()+        let before = try await fixture.repository.emptyLibraryRowCounts()++        // The same flock the repository takes, held for longer than its+        // two-second interactive timeout. `flock` is per open file description,+        // so a second `open` in this process is refused exactly as another+        // process would be — which is also why the lease is scoped: every read+        // below takes that lock too.+        do {+            let lease = try await CrossProcessLibraryLock.acquire(+                mode: .exclusive, at: fixture.configuration.lockURL, timeout: .seconds(1))+            await #expect(throws: LibraryRepositoryError.self) {+                _ = try await fixture.repository.emptyLibrary()+            }+            withExtendedLifetime(lease) {}+        }++        #expect(+            !FileManager.default.fileExists(atPath: fixture.configuration.emptySidecarURL.path),+            "a pass that deleted nothing must leave no record of an interruption")+        #expect(await fixture.repository.interruptedEmpty() == nil)+        #expect(try await fixture.repository.emptyLibraryRowCounts() == before)+        await fixture.tearDown()+    }++    /// The chunk loop terminates on **progress**, not on a fetch running dry.+    ///+    /// A save that neither throws nor removes the rows used to spin the loop+    /// forever, holding the exclusive lock and the actor with it. It is reported+    /// in the same shape a failed save is, so the row says what happened and the+    /// sidecar stays for the next run.+    @Test("A chunk that saves without removing its rows is reported, not looped")+    func aChunkThatMakesNoProgressIsReported() async throws {+        let strategy = RollingBackSaveStrategy()+        let fixture = try await EmptyLibraryFixture(saveStrategy: strategy)+        try await fixture.seedGoldenLibrary()+        let before = try await fixture.repository.emptyLibraryRowCounts()++        strategy.arm()+        let report = try await fixture.repository.emptyLibrary()++        guard case .interrupted(let phase, let reason) = report.outcome else {+            Issue.record("expected an interrupted report, got \(report.outcome)")+            return+        }+        #expect(phase == "1 entries")+        #expect(reason == "no progress in Entry")+        #expect(report.rowsDeleted == 0, "a rolled-back chunk deleted nothing")+        #expect(report.remaining == before.values.reduce(0, +))+        #expect(report.cleanup == .skipped)+        #expect(try await fixture.repository.emptyLibraryRowCounts() == before)+        #expect(await fixture.repository.interruptedEmpty() != nil)+        await fixture.tearDown()+    }++    // MARK: - Req 3.5: the exclusion and the deferred re-fire++    /// An emptying started while an import or a reconcile holds the exclusion+    /// flag would clear that flag at its own end (both hold it through a+    /// `defer`), un-fencing the other operation's tail. So it refuses, before+    /// anything is listed, written or deleted.+    @Test("An emptying refuses while another bulk operation holds the flag")+    func aBulkOperationInFlightRefusesTheRun() async throws {+        let fixture = try await EmptyLibraryFixture()+        try await fixture.seedGoldenLibrary()+        let before = try await fixture.repository.emptyLibraryRowCounts()++        await fixture.repository.setBulkOperationInProgressForTesting(true)+        await #expect(+            throws: LibraryRepositoryError.libraryBusy(operation: "emptying the library")+        ) {+            _ = try await fixture.repository.emptyLibrary()+        }++        #expect(+            await fixture.repository.bulkOperationInProgressForTesting,+            "the refusal must leave the other operation's fence up")+        #expect(try await fixture.repository.emptyLibraryRowCounts() == before)+        #expect(+            !FileManager.default.fileExists(atPath: fixture.configuration.emptySidecarURL.path),+            "the refusal precedes the sidecar")+        await fixture.repository.setBulkOperationInProgressForTesting(false)+        await fixture.tearDown()+    }++    @Test(+        "A reconcile deferred during the emptying re-fires afterwards",+        arguments: [true, false])+    func aDeferredReconcileReFires(completes: Bool) async throws {+        let strategy = FailAfterSaveStrategy()+        let fixture = try await EmptyLibraryFixture(saveStrategy: strategy)+        try await fixture.seedGoldenLibrary()+        if !completes { strategy.failAfter = 1 }++        // Stand in for the arrival debounce firing while the pass holds the+        // flag: the pass returns empty and records that it owes one.+        await fixture.repository.setBulkOperationInProgressForTesting(true)+        let deferred = try await fixture.repository.reconcileAfterSync()+        #expect(deferred.isEmpty)+        #expect(await fixture.repository.reconcileDeferredForTesting)+        // The flag is the pass's own to hold: the deferral above stands in for+        // a trigger that arrived while it was up, and the pass now refuses to+        // start while someone else still holds it (T-2118 review). Dropping it+        // here leaves exactly the state the pass's own `defer` would.+        await fixture.repository.setBulkOperationInProgressForTesting(false)++        let report = try await fixture.repository.emptyLibrary()+        #expect(report.isCompleted == completes)++        #expect(await fixture.repository.reconcileDeferredForTesting == false)+        #expect(await fixture.repository.bulkOperationInProgressForTesting == false)+        // The re-fired pass is the session's first, so it runs the duplicate+        // phase unconditionally — which is the witness that it ran at all.+        #expect(await fixture.repository.hasRunDuplicatePhaseThisSessionForTesting)+        await fixture.tearDown()+    }++    // MARK: - Req 3.6, 3.7: the spool and the owed markers++    @Test("Everything the spool and the marker directory held at the start is gone")+    func theListedSpoolAndMarkersAreDiscarded() async throws {+        let fixture = try await EmptyLibraryFixture()+        try await fixture.seedGoldenLibrary()+        let seeded = try fixture.seedSpool()++        let inventory = try await fixture.repository.emptyLibraryInventory()+        #expect(inventory.waitingCaptures == 1)+        #expect(inventory.setAsideCaptures == 1)++        let report = try await fixture.repository.emptyLibrary()++        #expect(report.outcome == .completed)+        #expect(report.cleanup == .done)+        #expect(try await fixture.spoolInventory().isEmpty)+        #expect(try fixture.owedMarker().pending().isEmpty)+        #expect(!FileManager.default.fileExists(atPath: seeded.reportURL.path))+        await fixture.tearDown()+    }++    /// Q13 and Q24: the cutoff is the listing taken when the deletion begins. A+    /// record the extension publishes while the pass runs is not in it and+    /// survives; so does a marker, and so does a staging file that appears after+    /// the listing.+    @Test("Captures and markers that land during the pass survive it")+    func lateArrivalsInTheSpoolSurvive() async throws {+        let strategy = SpoolWritingSaveStrategy()+        let fixture = try await EmptyLibraryFixture(saveStrategy: strategy)+        try await fixture.seedGoldenLibrary()+        let seeded = try fixture.seedSpool()+        strategy.arm(configuration: fixture.configuration)++        let report = try await fixture.repository.emptyLibrary()++        #expect(strategy.didWrite)+        #expect(report.outcome == .completed)+        #expect(report.cleanup == .done, "the late files are not this pass's to remove")++        let remaining = try await fixture.spoolInventory()+        #expect(remaining.waiting == [strategy.lateRecordID])+        #expect(remaining.incomingNames == [SpoolWritingSaveStrategy.lateIncomingName])+        #expect(!remaining.incomingNames.contains(seeded.incomingName))+        #expect(remaining.setAside.isEmpty)+        #expect(remaining.refusals.isEmpty)+        #expect(!remaining.hasReport)+        #expect(try fixture.owedMarker().pending().count == 1)+        await fixture.tearDown()+    }++    /// Req 4.2: the verdict comes from re-listing, because `ExportOwedMarker`+    /// clears without reporting and `discard` is deliberately quiet about a file+    /// that was already gone.+    @Test("A spool directory that cannot be emptied fails the cleanup and nothing else")+    func anUnremovableSpoolFailsCleanupOnly() async throws {+        let fixture = try await EmptyLibraryFixture()+        try await fixture.seedGoldenLibrary()+        _ = try fixture.seedSpool()++        let quarantine = fixture.configuration.pendingCapturesQuarantineURL+        try FileManager.default.setAttributes(+            [.posixPermissions: 0o500], ofItemAtPath: quarantine.path)+        defer {+            try? FileManager.default.setAttributes(+                [.posixPermissions: 0o700], ofItemAtPath: quarantine.path)+        }++        let report = try await fixture.repository.emptyLibrary()++        #expect(report.outcome == .completed)+        guard case .failed = report.cleanup else {+            Issue.record("expected a failed cleanup, got \(report.cleanup)")+            return+        }+        let after = try await fixture.repository.emptyLibraryRowCounts()+        #expect(after.values.allSatisfy { $0 == 0 }, "the rows still go")+        #expect(+            !FileManager.default.fileExists(atPath: fixture.configuration.emptySidecarURL.path),+            "the sidecar is cleared whatever the cleanup verdict")+        await fixture.tearDown()+    }++    /// The other half of that verdict: a spool the pass cannot **read back** is+    /// not a spool it emptied.+    ///+    /// The re-listing and an empty listing used to be one `try?`, so an+    /// unreadable directory — a mode that changed under the pass, or EPERM under+    /// file protection on a locked screen, which is the shape+    /// `docs/agent-notes/testing.md` records for this area — reported a clean+    /// spool. The directory is sealed on the first save rather than up front,+    /// because the cutoff listing runs before the first save and would refuse+    /// the run instead (Q13).+    @Test("A spool that cannot be re-listed fails the cleanup and nothing else")+    func anUnreadableSpoolFailsCleanupOnly() async throws {+        let strategy = SpoolSealingSaveStrategy()+        let fixture = try await EmptyLibraryFixture(saveStrategy: strategy)+        try await fixture.seedGoldenLibrary()+        _ = try fixture.seedSpool()++        let sealed = fixture.configuration.pendingCapturesPendingURL+        strategy.arm(directory: sealed)+        defer {+            try? FileManager.default.setAttributes(+                [.posixPermissions: 0o700], ofItemAtPath: sealed.path)+        }++        let report = try await fixture.repository.emptyLibrary()++        #expect(strategy.didSeal, "the pass never reached a save, so nothing was sealed")+        #expect(report.outcome == .completed)+        #expect(report.rowsDeleted > 0)+        guard case .failed(let reason) = report.cleanup else {+            Issue.record("expected a failed cleanup, got \(report.cleanup)")+            return+        }+        #expect(reason.contains("re-listed"), "the verdict must name what could not be read")++        let after = try await fixture.repository.emptyLibraryRowCounts()+        #expect(after.values.allSatisfy { $0 == 0 }, "the rows still go")++        // Before the teardown, or the directory the fixture removes still holds+        // one nothing may descend into.+        try FileManager.default.setAttributes(+            [.posixPermissions: 0o700], ofItemAtPath: sealed.path)+        await fixture.tearDown()+    }++    /// Q29 for the steps **after** the sweeps: the re-validation runs when every+    /// deletion is already committed, so a failure there costs the published+    /// diagnoses and is reported beside the count — never thrown over it.+    ///+    /// Arranged through the repository's own `shutdown()` rather than a new+    /// seam: the strategy queues one from a save, and the actor services it at+    /// the first suspension after the sweeps, which is the shared lock the+    /// re-validation takes. The sweeps themselves cannot be hit by it — they run+    /// inside one synchronous locked closure, which never suspends.+    @Test("A re-validation that fails after the rows are gone reports the count")+    func aFailedRevalidationStillReportsTheCount() async throws {+        let strategy = ShutdownAfterSweepsSaveStrategy()+        let fixture = try await EmptyLibraryFixture(saveStrategy: strategy)+        try await fixture.seedGoldenLibrary()+        let before = try await fixture.repository.emptyLibraryRowCounts()++        strategy.arm(repository: fixture.repository)+        let report = try await fixture.repository.emptyLibrary()++        #expect(report.outcome == .completed)+        #expect(report.rowsDeleted == before.values.reduce(0, +))+        #expect(report.remaining == 0)+        guard case .failed(let reason) = report.cleanup else {+            Issue.record("expected a failed cleanup, got \(report.cleanup)")+            return+        }+        #expect(reason.contains("re-validated"))+        await fixture.tearDown()+    }++    // MARK: - Req 5.1: the round trip++    /// Export, empty, import, export. Payload to payload, which is archive+    /// equality with the export metadata masked (Q15) — the payload carries no+    /// `exportedAt` and no file name at all, so the comparison is exactly the+    /// one the requirement asks for.+    @Test("An archive exported before the emptying restores the library exactly")+    func exportEmptyImportExportIsExact() async throws {+        let fixture = try await EmptyLibraryFixture()+        try await fixture.seedGoldenLibrary()++        let before = try await fixture.repository.backupV14Snapshot()+        let report = try await fixture.repository.emptyLibrary()+        #expect(report.outcome == .completed)++        try await fixture.repository.confirmImport(plan: EmptyLibraryFixture.plan(before))+        let after = try await fixture.repository.backupV14Snapshot()++        #expect(after == before)+        await fixture.tearDown()+    }++    /// Q15's second half: a second import of the same archive changes no stored+    /// value and moves no modification timestamp.+    ///+    /// **Asserted as the library before and after, not as `hasChanges` at each+    /// save.** The design's testing table asks for the second form; the+    /// importer's `apply` deliberately writes every scalar column unconditionally+    /// (see `applyThumbnail`'s own note — the value guards this project relies on+    /// are the reconcilers', and only the cover is guarded, because only the+    /// cover's rewrite costs a file and an asset upload). So every one of the+    /// second import's nine saves reports pending changes while writing back the+    /// values that are already there. What Req 5.1 is a promise about is the+    /// *stored* result, and the whole payload — every column of every record —+    /// is a strictly stronger statement of it than a count of dirty saves.+    @Test("A second import of the same archive changes no stored value")+    func aSecondImportOfTheArchiveDirtiesNothing() async throws {+        let strategy = ChangeRecordingSaveStrategy()+        let fixture = try await EmptyLibraryFixture(saveStrategy: strategy)+        try await fixture.seedGoldenLibrary()++        let archive = try await fixture.repository.backupV14Snapshot()+        _ = try await fixture.repository.emptyLibrary()+        try await fixture.repository.confirmImport(plan: EmptyLibraryFixture.plan(archive))+        let restored = try await fixture.repository.backupV14Snapshot()+        let stamps = try await fixture.repository.modificationStampsForTesting()++        strategy.arm()+        try await fixture.repository.confirmImport(plan: EmptyLibraryFixture.plan(archive))++        #expect(strategy.saves > 0, "the second import must have reached its saves")+        #expect(try await fixture.repository.backupV14Snapshot() == restored)+        #expect(try await fixture.repository.modificationStampsForTesting() == stamps)+        await fixture.tearDown()+    }++    /// Req 2.5: the archive holds the library as it was when the export ran. A+    /// record committed after it is deleted with the rest and is not in the file.+    @Test("A capture made after the export is absent from it and goes with the rest")+    func aCaptureAfterTheExportIsNotInTheArchive() async throws {+        let fixture = try await EmptyLibraryFixture()+        try await fixture.seedGoldenLibrary()++        let archive = try await fixture.repository.backupV14Snapshot()+        let late = try await fixture.repository.capture(+            CaptureDraft(+                captureTitle: "Chapter 9 - After the export",+                captureTitleSource: .safariDocument,+                rawURLString: "https://late.example/read?chapter=9"))++        #expect(!archive.entries.contains { $0.id == late.id })+        _ = try await fixture.repository.emptyLibrary()+        let after = try await fixture.repository.emptyLibraryRowCounts()+        #expect(after.values.allSatisfy { $0 == 0 })+        await fixture.tearDown()+    }++    // MARK: - Req 3.9: the session with no directory rows++    /// The action does not re-seed the defaults (Q6), so the library holds zero+    /// work types and zero roles until the next open. Capture and the editor's+    /// reads have to tolerate that — the standing rule for a list not yet synced.+    @Test("Capture and the editor's reads work with no directory rows at all")+    func theEmptiedSessionToleratesEmptyDirectories() async throws {+        let fixture = try await EmptyLibraryFixture()+        try await fixture.seedGoldenLibrary()+        _ = try await fixture.repository.emptyLibrary()++        #expect(try await fixture.repository.workTypeOptions().isEmpty)+        #expect(try await fixture.repository.creatorRoleOptions().isEmpty)+        #expect(try await fixture.repository.creatorRoles().isEmpty)++        let entry = try await fixture.repository.capture(+            CaptureDraft(+                captureTitle: "Chapter 1 - Nothing seeded",+                captureTitleSource: .safariDocument,+                rawURLString: "https://empty.example/read?chapter=1"))+        #expect(entry.hostname == "empty.example")++        // The work editor's draft over an empty type list is the untyped one —+        // "other" — with no roles to offer beside it.+        let work = try await fixture.repository.createWork(+            NewWorkDraft(displayTitle: "A Work", hostname: "empty.example"))+        #expect(work.typeDisplay == .untyped)+        try await fixture.repository.updateWork(+            id: work.id,+            draft: WorkMetadataDraft(+                displayTitle: "A Work", typeAssignment: .none, genreTags: [],+                genericNotes: "", workStatus: .ongoing, readingStatus: .reading, verdict: "",+                releaseDays: [], membership: nil, thumbnail: .keep))+        #expect(try await fixture.repository.work(id: work.id).typeDisplay == .untyped)+        await fixture.tearDown()+    }+}++// MARK: - Save strategies++/// Saves, then runs the whole-library validator and records any **tuple**+/// diagnosis the chunk introduced (Req 3.3, Q21).+///+/// Tolerated states are not counted, and that is structural rather than a+/// filter: `tupleDiagnoses` holds `.siteTuple` alone, and a work without a+/// membership or an entry without a site — both of which appear transiently+/// between the phases — is never one.+private final class ValidatingSaveStrategy: RepositorySaveStrategy, @unchecked Sendable {+    private let lock = NSLock()+    private var armed = false+    private var previous: [String: LibraryValidationError] = [:]+    private var offences: [String] = []+    private var validated = 0++    func arm(baseline: [String: LibraryValidationError]) {+        lock.withLock {+            armed = true+            previous = baseline+        }+    }++    var introduced: [String] { lock.withLock { offences } }+    var savesValidated: Int { lock.withLock { validated } }++    func save(_ context: ModelContext) throws {+        try context.save()+        guard lock.withLock({ armed }) else { return }+        let now = try LibraryValidator.validate(context: context).tupleDiagnoses+        lock.withLock {+            validated += 1+            for hostname in now.keys where previous[hostname] == nil { offences.append(hostname) }+            previous = now+        }+    }+}++/// Lets the first `failAfter` saves commit and throws from every one after them,+/// so a chunk boundary can be killed at a chosen point. `nil` never fails.+private final class FailAfterSaveStrategy: RepositorySaveStrategy, @unchecked Sendable {+    private let lock = NSLock()+    private var _failAfter: Int?+    private var saves = 0++    var failAfter: Int? {+        get { lock.withLock { _failAfter } }+        set { lock.withLock { _failAfter = newValue; saves = 0 } }+    }++    func save(_ context: ModelContext) throws {+        let refuse = lock.withLock { () -> Bool in+            saves += 1+            guard let limit = _failAfter else { return false }+            return saves > limit+        }+        guard !refuse else { throw CocoaError(.fileWriteUnknown) }+        try context.save()+    }+}++/// Rolls every armed save back instead of committing it, and **does not throw**:+/// the shape a chunk loop that terminated on an empty fetch rather than on+/// progress would spin on forever.+private final class RollingBackSaveStrategy: RepositorySaveStrategy, @unchecked Sendable {+    private let lock = NSLock()+    private var armed = false++    func arm() { lock.withLock { armed = true } }++    func save(_ context: ModelContext) throws {+        guard lock.withLock({ armed }) else {+            try context.save()+            return+        }+        context.rollback()+    }+}++/// Inserts one Entry on the save that takes the last `Site` row — phase 8's —+/// so the pass has to sweep the phase list again to find it (Q31).+private final class LateArrivalSaveStrategy: RepositorySaveStrategy, @unchecked Sendable {+    private let lock = NSLock()+    private var armed = false+    private var inserted = false++    func arm() { lock.withLock { armed = true } }+    var didInsert: Bool { lock.withLock { inserted } }++    func save(_ context: ModelContext) throws {+        try context.save()+        guard lock.withLock({ armed && !inserted }) else { return }+        guard try context.fetchCount(FetchDescriptor<Site>()) == 0 else { return }+        let url = "https://arrived.example/read?chapter=1"+        context.insert(+            Entry(+                captureTitle: "Chapter 1 - Arrived mid-pass", captureTitleSource: .host,+                rawURLString: url, hostname: "arrived.example", entryIdentityKey: url,+                timestamp: Date(timeIntervalSince1970: 1_800_000_000)))+        try context.save()+        lock.withLock { inserted = true }+    }+}++/// Writes one preserved capture and one owed marker **by path** on the first+/// save, the way the share extension does: a durable write into `incoming/` and+/// an atomic rename into `pending/`. It also leaves a staging file behind, which+/// is the `incoming/` name Q24 says a pass that started before it must not+/// touch.+private final class SpoolWritingSaveStrategy: RepositorySaveStrategy, @unchecked Sendable {+    static let lateIncomingName = "late-staging.json"++    private let lock = NSLock()+    private var configuration: LibraryConfiguration?+    private var wrote = false++    let lateRecordID = UUID()++    func arm(configuration: LibraryConfiguration) {+        lock.withLock { self.configuration = configuration }+    }++    var didWrite: Bool { lock.withLock { wrote } }++    func save(_ context: ModelContext) throws {+        try context.save()+        let paths = lock.withLock { () -> LibraryConfiguration? in+            guard let configuration, !wrote else { return nil }+            wrote = true+            return configuration+        }+        guard let paths else { return }++        let staged = paths.pendingCapturesIncomingURL.appending(path: "\(lateRecordID).json")+        try Data("{}".utf8).write(to: staged)+        try FileManager.default.moveItem(+            at: staged,+            to: paths.pendingCapturesPendingURL.appending(path: "\(lateRecordID).json"))+        try Data("{}".utf8).write(+            to: paths.pendingCapturesIncomingURL.appending(path: Self.lateIncomingName))+        try ExportOwedMarker(directory: paths.exportOwedURL).mark()+    }+}++/// Makes one directory unreadable on the first save, so the pass's re-listing+/// fails where its cutoff listing succeeded.+private final class SpoolSealingSaveStrategy: RepositorySaveStrategy, @unchecked Sendable {+    private let lock = NSLock()+    private var directory: URL?+    private var sealed = false++    func arm(directory: URL) { lock.withLock { self.directory = directory } }+    var didSeal: Bool { lock.withLock { sealed } }++    func save(_ context: ModelContext) throws {+        try context.save()+        let target = lock.withLock { () -> URL? in+            guard let directory, !sealed else { return nil }+            sealed = true+            return directory+        }+        guard let target else { return }+        try FileManager.default.setAttributes(+            [.posixPermissions: 0o000], ofItemAtPath: target.path)+    }+}++/// Queues one `shutdown()` from the first save. It cannot land on a chunk — the+/// sweeps run inside a single synchronous locked closure — so the actor services+/// it at the next suspension, which is the shared lock the post-sweep+/// re-validation takes, and that re-validation then fails on a released+/// container.+private final class ShutdownAfterSweepsSaveStrategy: RepositorySaveStrategy, @unchecked Sendable {+    private let lock = NSLock()+    private var repository: LibraryRepository?++    func arm(repository: LibraryRepository) { lock.withLock { self.repository = repository } }++    func save(_ context: ModelContext) throws {+        try context.save()+        let target = lock.withLock { () -> LibraryRepository? in+            defer { repository = nil }+            return repository+        }+        guard let target else { return }+        Task { await target.shutdown() }+    }+}++/// Counts the saves a pass makes once it is armed, so "the second import+/// reached its commit path" is a fact rather than an assumption.+private final class ChangeRecordingSaveStrategy: RepositorySaveStrategy, @unchecked Sendable {+    private let lock = NSLock()+    private var armed = false+    private var _saves = 0++    func arm() { lock.withLock { armed = true; _saves = 0 } }+    var saves: Int { lock.withLock { _saves } }++    func save(_ context: ModelContext) throws {+        lock.withLock { if armed { _saves += 1 } }+        try context.save()+    }+}++// MARK: - The fixture++/// A repository over a fresh on-disk store, with the paths the pass's listings+/// and sidecar live at, torn down with the test.+private final class EmptyLibraryFixture {+    let directory: URL+    let configuration: LibraryConfiguration+    private(set) var repository: LibraryRepository++    init(saveStrategy: any RepositorySaveStrategy = ModelContextSaveStrategy()) async throws {+        directory = FileManager.default.temporaryDirectory+            .appending(path: "AsterismEmptyLibraryTests-\(UUID())", directoryHint: .isDirectory)+        try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)+        configuration = LibraryConfiguration(rootDirectory: directory)+        repository = try await LibraryRepository.openForApp(+            configuration, capabilities: .multiSite,+            clock: FixedRepositoryClock(M5Fixture.epoch), saveStrategy: saveStrategy).repository+    }++    /// Releases the container **before** the store directory goes, the way+    /// `M4EmptyLibraryPerformanceStore` does: `deinit` cannot await, so the+    /// release is the test's to ask for, and pulling the store out from under a+    /// live container is what every M4 suite shuts down to avoid. The `deinit`+    /// below stays as the backstop for a test that fails before it gets here.+    func tearDown() async {+        await repository.shutdown()+        try? FileManager.default.removeItem(at: directory)+    }++    /// Releases the container before constructing the next one, which is what+    /// `shutdown()` exists for.+    func reopen(+        saveStrategy: any RepositorySaveStrategy = ModelContextSaveStrategy()+    ) async throws {+        await repository.shutdown()+        repository = try await LibraryRepository.openForApp(+            configuration, capabilities: .multiSite,+            clock: FixedRepositoryClock(M5Fixture.epoch), saveStrategy: saveStrategy).repository+    }++    /// The golden archive, through the real commit path: one record of every+    /// kind the payload can hold, so every entity the schema declares ends up+    /// with rows.+    ///+    /// One record of it is normalised afterwards. The golden's taught site is a+    /// hand-built wire record carrying a junk-suffix rule, which is a shape the+    /// import's own update path forbids: `applyDesignation` forces the rule nil+    /// off `.articles` and calls that "this pass's own rule", while+    /// `ArchiveRecordBuilders.makeSite` copies the record verbatim on the insert+    /// path. So the same archive imported into an empty library and imported+    /// over an existing row produce different Site rows — a **pre-existing+    /// divergence between the importer's two branches**, unrelated to the+    /// emptying, which would otherwise be the one thing a second import moves.+    /// Dropping it here keeps this suite about the pass; the archive still+    /// carries a junk rule on the `.articles` site, which is where the column+    /// belongs.+    ///+    /// **The real fix was attempted and does not fit under a recorded golden.**+    /// Stating the rule once — `mode == .articles ? junkSuffixRule : nil`, used+    /// by both `makeSite` and `applyDesignation` — makes the two paths agree, but+    /// `BackupGoldenExportTests` then fails twice: its taught site is the only+    /// one in the fixture carrying the column, so+    /// `goldenLibraryPopulatesEveryArray` loses its `junkSuffixRule != nil` and+    /// the byte comparison against `Fixtures/backup-14-15-golden.json` moves.+    /// Landing it means moving the column onto the fixture's `.articles` site and+    /// re-recording the archive, which is a deliberate act with an owner's+    /// procedure (`rule-citation-by-uuid` Q22) and not this suite's to take.+    func seedGoldenLibrary() async throws {+        try await repository.confirmImport(+            plan: try BackupImporter.plan(+                from: try BackupV14Codec.encode(+                    payload: BackupGoldenLibrary.payload,+                    metadata: BackupGoldenLibrary.metadata)))+        try await repository.dropJunkRulesOutsideArticlesForTesting()+    }++    /// One waiting capture, one set aside, one refusal receipt, a drain report+    /// and a staging file, plus one owed marker.+    ///+    /// Written **by path** rather than through `PendingCaptureSpool.preserve`,+    /// and deliberately: the listing and the discard address every one of these+    /// by name, so a seed that decoded record bodies would make this suite+    /// depend on the host being able to *read* the area. It cannot always —+    /// under `completeUnlessOpen` a locked screen turns every read into EPERM,+    /// which is what the spool's own suite is for (`docs/agent-notes/testing.md`).+    @discardableResult+    func seedSpool() throws -> (reportURL: URL, incomingName: String) {+        for directory in [+            configuration.pendingCapturesURL, configuration.pendingCapturesIncomingURL,+            configuration.pendingCapturesPendingURL, configuration.pendingCapturesQuarantineURL,+            configuration.pendingCapturesRefusalsURL,+        ] {+            try FileManager.default.createDirectory(+                at: directory, withIntermediateDirectories: true)+        }+        let incomingName = "abandoned-staging.json"+        try Data("{}".utf8).write(+            to: configuration.pendingCapturesPendingURL.appending(path: "\(UUID()).json"))+        try Data("{}".utf8).write(+            to: configuration.pendingCapturesQuarantineURL.appending(path: "\(UUID()).json"))+        try Data("{}".utf8).write(+            to: configuration.pendingCapturesRefusalsURL.appending(path: "\(UUID()).json"))+        try Data("{}".utf8).write(+            to: configuration.pendingCapturesIncomingURL.appending(path: incomingName))+        try Data("{}".utf8).write(to: configuration.pendingCaptureReportURL)+        try owedMarker().mark()++        return (configuration.pendingCaptureReportURL, incomingName)+    }++    func spoolInventory() async throws -> PendingCaptureInventory {+        try await PendingCaptureSpool(rootDirectory: directory).inventory()+    }++    func owedMarker() -> ExportOwedMarker {+        ExportOwedMarker(directory: configuration.exportOwedURL)+    }++    /// The plan `confirmImport` takes, straight off a payload the export just+    /// produced — what a reader restoring their own backup hands it.+    static func plan(_ payload: BackupV14Payload) -> BackupImportPlan {+        BackupImportPlan(+            metadata: BackupImportMetadata(+                formatVersion: BackupV14Document.formatVersion,+                schemaVersion: BackupV14Document.schemaVersion,+                appBuild: "empty-library-test",+                exportedAt: BackupGoldenLibrary.created, capabilityGate: "multi-site",+                entryCount: payload.entries.count, workCount: payload.works.count),+            payload: payload,+            counts: LibraryRecordCounts(+                entries: payload.entries.count, works: payload.works.count,+                sites: payload.sites.count, titlePatterns: payload.titlePatterns.count,+                urlRulePatterns: payload.urlRules.count))+    }++    deinit { try? FileManager.default.removeItem(at: directory) }+}++// MARK: - Repository probes++extension LibraryRepository {++    /// Every entity's row count, read through the pass's own phase list.+    ///+    /// Circular only in appearance: `ModelContractTests` pins that list against+    /// `AsterismSchemaV15.models`, so counting through it is counting the+    /// schema's entities — and the alternative, a second hand-written list of+    /// seventeen fetches, is one more thing to forget at the next bump.+    fileprivate func emptyLibraryRowCounts() async throws -> [String: Int] {+        try await withLockedContext(mode: .shared, operation: "counting every entity") { context in+            var counts: [String: Int] = [:]+            for phase in Self.emptyLibraryPhases {+                for entity in phase.entities { counts[entity.name] = try entity.count(context) }+            }+            return counts+        }+    }++    /// See `EmptyLibraryFixture.seedGoldenLibrary` for why the seed needs this.+    fileprivate func dropJunkRulesOutsideArticlesForTesting() async throws {+        try await withLockedContext(+            mode: .exclusive, operation: "normalising the seeded Site designations"+        ) { context in+            for site in try context.fetch(FetchDescriptor<Site>())+            where site.mode != .articles && site.junkSuffixRule != nil {+                site.junkSuffixRule = nil+            }+            try saveStrategy.save(context)+        }+    }++    fileprivate func tupleDiagnosesForTesting() async throws -> [String: LibraryValidationError] {+        try await withLockedContext(mode: .shared, operation: "reading tuple diagnoses") { context in+            try LibraryValidator.validate(context: context).tupleDiagnoses+        }+    }++    /// The modification stamps of **every entity that carries one**, so "the+    /// second import moved no timestamp" is a statement about every table that+    /// can answer it.+    ///+    /// Ten of the seventeen. The other seven have no `modifiedAt` to move:+    /// `Site`, `TitlePattern`, `URLRulePattern`, `WorkSiteMembership`,+    /// `WorkDistinctPair` and the two suppressions are designations, immutable+    /// revisions, join rows or dated actions. Whether the second import moved+    /// anything on those is answered by the payload comparison beside this one,+    /// which covers every column of every record.+    fileprivate func modificationStampsForTesting() async throws -> [String: [Date]] {+        try await withLockedContext(mode: .shared, operation: "reading modification stamps") {+            context in+            func stamps<Model: PersistentModel>(+                _ type: Model.Type, _ value: (Model) -> Date+            ) throws -> [Date] {+                try context.fetch(FetchDescriptor<Model>()).map(value).sorted()+            }+            return [+                "Entry": try stamps(Entry.self) { $0.modifiedAt },+                "Work": try stamps(Work.self) { $0.modifiedAt },+                "Series": try stamps(Series.self) { $0.modifiedAt },+                "WorkLink": try stamps(WorkLink.self) { $0.modifiedAt },+                "WorkCredit": try stamps(WorkCredit.self) { $0.modifiedAt },+                "Character": try stamps(CharacterRecord.self) { $0.modifiedAt },+                "Place": try stamps(Place.self) { $0.modifiedAt },+                "WorkType": try stamps(WorkTypeEntity.self) { $0.modifiedAt },+                "Creator": try stamps(Creator.self) { $0.modifiedAt },+                "CreatorRole": try stamps(CreatorRole.self) { $0.modifiedAt },+            ]+        }+    }++    fileprivate func setBulkOperationInProgressForTesting(_ value: Bool) {+        bulkOperationInProgress = value+    }++    fileprivate var bulkOperationInProgressForTesting: Bool { bulkOperationInProgress }+    fileprivate var reconcileDeferredForTesting: Bool { reconcileDeferred }+    fileprivate var hasRunDuplicatePhaseThisSessionForTesting: Bool {+        hasRunDuplicatePhaseThisSession+    }+}
Packages/AsterismCore/Tests/AsterismCoreTests/M4EmptyLibraryPerformanceTests.swift Added +166 / -0
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/M4EmptyLibraryPerformanceTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/M4EmptyLibraryPerformanceTests.swiftnew file mode 100644index 00000000..a4edf257--- /dev/null+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/M4EmptyLibraryPerformanceTests.swift@@ -0,0 +1,166 @@+import Foundation+import SwiftData+import Testing++@testable import AsterismCore++// MARK: - `empty-library` Req 3.2 — what deleting the whole library costs at M4 scale++/// The emptying over the covered M4 fixture: 5,000 Entries, 1,000 Works, their+/// memberships, schedules and 200 covers, deleted row by row through the+/// repository's ordinary change-tracked path.+///+/// **Reported, with a ceiling rather than a budget** (Q18). Nothing here is a+/// requirement the feature has to meet — the action is a `Development`-only+/// row over a library the reader has just backed up — so the number is a+/// baseline for the next run to be compared against, and the assertion refuses+/// only a different order of magnitude. The band is in+/// `specs/empty-library/verification-run.md`.+///+/// The per-phase lines the pass logs under `category: EmptyLibrary` were the+/// evidence for the design's first risk — whether the inverses that are *not*+/// detached first (`Work.entries`, the two membership inverses,+/// `Work.characters`) dominate now that `Site.entries` no longer does. **They+/// do not**, and the deletion order is why: children go before parents, so+/// every parent's inverse array is already empty by the time the parent is+/// deleted. Over nine measured passes the works phase — where that maintenance+/// would land — is 4.5% of the pass, 0.26 ms per Work, the cheapest phase per+/// row in the whole list. No second entry in `SiteInverseReachTests` is+/// earned.+///+/// **Host-only, like every M4 suite**: the package test target is in no scheme's+/// test action, so these numbers are comparable to a later run of the same+/// command on the same machine and to nothing else.+@Suite(+    "M4 empty-library scale budgets", .serialized,+    .enabled(if: ProcessInfo.processInfo.environment["ASTERISM_RUN_PHYSICAL_PERFORMANCE"] == "1"))+struct M4EmptyLibraryPerformanceTests {++    /// The regression ceiling, drawn from the measured band at the ratio the+    /// other M4 arms draw theirs: **roughly twice the recorded median**, as+    /// `dedupeLinksCeiling` (25 ms over ~11 ms), `dedupeCreditsCeiling` (130 ms+    /// over 62.6 ms) and `convergeCreatorsCeiling` (20 ms over ~10 ms) all do.+    ///+    /// Run **on its own**, four runs of three samples measured medians of+    /// **5.544 s, 5.752 s, 5.861 s and 5.836 s** over the covered fixture's+    /// 7,009 rows, every sample between 5.541 s and 6.244 s — a band that+    /// barely notices the host, since those runs were taken as the one-minute+    /// load average fell from 80 to 5 and the medians moved 5.7%, *upwards*.+    ///+    /// **Run inside the whole target, which is where it will actually be+    /// measured, the same arm reads 7.712 s** (min 6.410 s, max 8.169 s,+    /// spread 1.27×) — twenty minutes of other M4 suites have been through the+    /// page cache by the time it starts. The ceiling is drawn at ~2× *that*+    /// rather than ~2× the solo band: 16 s is 2.07× the in-target median and+    /// 1.96× the slowest sample seen anywhere. Drawn from the solo runs alone+    /// it would have been 12 s, which the in-target run clears by only 1.5× —+    /// too little for a machine whose contended runs `CLAUDE.md` records+    /// doubling untouched arms.+    ///+    /// The ratio is the one the other M4 arms use — `dedupeLinksCeiling`+    /// (25 ms over ~11 ms), `dedupeCreditsCeiling` (130 ms over 62.6 ms),+    /// `convergeCreatorsCeiling` (20 ms over ~10 ms) — and the reasoning is+    /// theirs too: generous enough that host variance cannot fire it, tight+    /// enough that a pass which stopped being linear in the rows does.+    /// `specs/empty-library/verification-run.md` has every run and the+    /// per-phase split.+    private let regressionCeiling = Duration.seconds(16)++    /// Three, not twenty. Every sample needs its own freshly seeded 5,000-Entry+    /// store — the pass leaves nothing behind to measure a second time — so a+    /// sample costs a whole fixture seed plus the pass, and twenty would add+    /// most of an hour to a target that already takes about twenty minutes.+    private let sampleCount = 3++    @Test("Emptying the covered M4 fixture (Req 3.2, informational)")+    func emptyingTheCoveredFixture() async throws {+        var samples: [Duration] = []+        let clock = ContinuousClock()+        for sample in 1...sampleCount {+            let store = try await M4EmptyLibraryPerformanceStore()+            let repository = try await store.openApp()++            let start = clock.now+            let report = try await repository.emptyLibrary()+            samples.append(clock.now - start)++            #expect(+                report.outcome == .completed,+                "sample \(sample): the pass must finish over a quiescent host library")+            #expect(report.remaining == 0)+            #expect(+                report.rowsDeleted > LibraryRepository.m4FixtureEntryCount,+                "sample \(sample) deleted \(report.rowsDeleted) rows, fewer than the fixture's entries")+            // The store is released before the next sample seeds its own: two+            // live containers over two stores is not the 134422 collision, but+            // it is 5,000 rows of page cache the next seed would be measured+            // against.+            await repository.shutdown()+        }++        let measured = PerformanceDistribution(samples)+        reportPerformance("empty-library-m4", measured)+        expectWithinCeiling("empty-library-m4", measured, regressionCeiling)+    }++    /// The regression floor beside a reported number, in the shape the other M4+    /// suites established: generous enough that measurement noise cannot fire+    /// it, so a failure is a statement about the code.+    private func expectWithinCeiling(+        _ label: String,+        _ measured: PerformanceDistribution,+        _ ceiling: Duration,+        sourceLocation: SourceLocation = #_sourceLocation+    ) {+        #expect(+            measured.median <= ceiling,+            """+            \(label) median \(measured.median) exceeded the \(ceiling) regression \+            ceiling (p95 \(measured.p95), spread \(measured.spread)x) — this is not a \+            requirement budget; it is twice the band recorded in \+            specs/empty-library/verification-run.md, so read it as the pass \+            having stopped being linear in the rows rather than as a slow host+            """,+            sourceLocation: sourceLocation)+    }+}++// MARK: - Fixture++/// The covered 5,000-Entry fixture on disk, certified ready and reopened the way+/// the app opens it.+///+/// A private copy of `M4DuplicatePerformanceStore`'s shape rather than a shared+/// one, for that suite's own reason: the seeding repository has to be released+/// before anything is measured, and that is the only property the two share.+private final class M4EmptyLibraryPerformanceStore {+    let root: URL+    let configuration: LibraryConfiguration++    init() async throws {+        root = FileManager.default.temporaryDirectory+            .appending(+                path: "asterism-m4-empty-perf-\(UUID().uuidString)", directoryHint: .isDirectory)+        configuration = LibraryConfiguration(rootDirectory: root)+        try FileManager.default.createDirectory(+            at: configuration.storeURL.deletingLastPathComponent(),+            withIntermediateDirectories: true)++        let container = try LibraryRepository.openContainer(at: configuration.storeURL)+        let seeder = LibraryRepository.makeRepository(+            configuration, container, .m4, SystemRepositoryClock(), ModelContextSaveStrategy())+        try await seeder.seedM4PerformanceFixture(toleratedState: nil)+        try LibraryRepository.publishReadiness(at: configuration.readinessMarkerURL)+        withExtendedLifetime(container) {}+    }++    func openApp() async throws -> LibraryRepository {+        let (_, repository) = try await LibraryRepository.openForApp(+            configuration, capabilities: .multiSite)+        return repository+    }++    deinit {+        try? FileManager.default.removeItem(at: root)+    }+}
Packages/AsterismCore/Tests/AsterismCoreTests/ModelContractTests.swift Modified +21 / -0
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/ModelContractTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/ModelContractTests.swiftindex b2210165..9a1be65f 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/ModelContractTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/ModelContractTests.swift@@ -191,6 +191,27 @@ struct ModelContractTests {         #expect(AsterismSchemaV14.models.map { String(describing: $0) } == seventeen)     } +    /// `empty-library` Req 3.1: the deletion pass names **every** entity the+    /// live schema declares, so a schema that gains one the pass would leave+    /// behind fails here rather than leaving rows in a library the reader was+    /// told is empty.+    ///+    /// A set rather than a list: the pass's order is children before parents+    /// (Q11) and the schema's order is its own, and neither is a statement about+    /// the other.+    @Test("The empty-library phases cover exactly the live schema's entities")+    func emptyLibraryPhasesCoverTheSchema() {+        let phased = Set(+            LibraryRepository.emptyLibraryPhases.flatMap { $0.entities.map(\.name) })+        let declared = Set(AsterismSchemaV15.models.map { String(describing: $0) })+        #expect(+            phased == declared,+            """+            the emptying misses \(declared.subtracting(phased).sorted()) and \+            names \(phased.subtracting(declared).sorted()) that the schema does not+            """)+    }+     /// **V15 adds exactly one `Work` column and nothing else**, which is the     /// whole of this bump — and is therefore asserted as a *delta* rather than     /// as equality: the live `Work` entity must be the frozen one plus
Packages/AsterismCore/Tests/AsterismCoreTests/SiteInverseReachTests.swift Modified +8 / -1
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/SiteInverseReachTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/SiteInverseReachTests.swiftindex cbaa0a7f..863431be 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/SiteInverseReachTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/SiteInverseReachTests.swift@@ -48,8 +48,15 @@ struct SiteInverseReachTests {     /// read, above all — still fails this test. Adding to this list is a     /// deliberate act with a measurement behind it, which is the property the     /// guard exists to keep.+    /// The second entry is `empty-library`'s deletion pass (Q32), which deletes+    /// **every** Entry in the library a chunk at a time and so pays the same+    /// walk the reconciler's settling pass does — for the same reason and with+    /// the same repair. No other inverse is detached there: the rest are+    /// per-Work or per-Site arrays of tens of rows, and the M4 arm is what+    /// decides whether one of them ever earns an entry here.     private static let sanctioned: [(file: String, snippet: String)] = [-        (file: "DuplicateReconciler.swift", snippet: "site.entries?.removeAll")+        (file: "DuplicateReconciler.swift", snippet: "site.entries?.removeAll"),+        (file: "LibraryRepository+EmptyLibrary.swift", snippet: "site.entries?.removeAll"),     ]      private static func isSanctioned(file: String, code: String) -> Bool {
docs/agent-notes/testing.md Modified +41 / -0
diff --git a/docs/agent-notes/testing.md b/docs/agent-notes/testing.mdindex 68d2ce58..597cf61d 100644--- a/docs/agent-notes/testing.md+++ b/docs/agent-notes/testing.md@@ -52,6 +52,23 @@ usually when a run starts right after another simulator session. It is not a tes failure. Fix: `xcrun simctl shutdown all`, then rerun. If it persists, it has always cleared on the second retry. +## Two sessions on one simulator corrupt each other's runs++Since #77 the default `SIMULATOR` is `iPhone 18 Pro`, and every session installs+the `Development` app there under the one bundle id, `me.nore.ig.Asterism.dev`.+Two sessions testing at once therefore replace each other's install mid-run. The+shape it takes (2026-09-19, `empty-library`): `make test-quick` **exits 65 and+names failing tests with no recorded issue anywhere in the log**, and+`~/Library/Logs/DiagnosticReports/Asterism-*.ips` shows the app dying in dyld at+launch, `Symbol not found` for a Core symbol only one of the two branches has,+"Referenced from" one session's `Asterism.debug.dylib` and "Expected in" the+other's `AsterismCore.framework`. It is not a test failure and not a link error+in either branch.++Check for another run first (`ps -axo pid,etime,command | grep xcodebuild`), and+give a concurrent run a simulator of its own:+`make test-quick SIMULATOR='iPhone 17 Pro,OS=26.5'`. The same run is green there.+ ## Known flaky family: the double-submission guards, and the async-chain counts  Several `AsterismTests` cases assert a **call count** on a `MockLibraryProvider`@@ -1507,6 +1524,30 @@ the simulator runtime. Details, including the runs, are in `specs/catch-up-mode/verification-run.md`. They want a bugfix spec of their own; until then, a red `make test-ui` with exactly these four is not news. +## The share sheet in a UI test, and what it cannot prove++Three facts from `EmptyLibrarySettingsUITests` and the device bug behind+`empty-library` Decision 6.++- **The activity sheet's close button lives in another process.** It publishes+  `header.closeButton` with the label `Close`, so `app.buttons["Close"]` does not+  resolve it; match on `identifier == "header.closeButton" OR label == "Close"`.+  The fallback `swipeDown()` is not safe on a scrolling screen: a missed swipe+  scrolls Settings back to the top, which takes the Debug rows out of the+  accessibility tree and fails the *next* assertion.+- **No UI test can complete an activity**, so a share's completed arm is manual.+  A journey that dismisses the sheet cannot tell "the activity reported+  cancelled" from "nothing reported and the grace period ran out": both end idle.+  `testDismissingTheShareSheetDeletesNothing` was green before and after+  Decision 6's fix. The pure `ExportOutcomeLedger` tests are the net for the+  ordering; the runbook is the net for the wiring.+- **`completionWithItemsHandler` runs after a SwiftUI sheet's `onDismiss`** when+  the activity is Save to Files. Code that treats the dismissal as the end of the+  share sees the completion late or not at all. `documentExporter` numbers its+  presentations and delivers the report when it arrives for that reason. The+  Export Backup and Markdown export rows still delete their staged file at the+  dismissal (T-2346).+ ## Misc  - `make test-only TEST=AsterismTests/SomeSuite` runs one suite; `TEST` also
specs/OVERVIEW.md Modified +18 / -0
diff --git a/specs/OVERVIEW.md b/specs/OVERVIEW.mdindex 878d96e4..543ad001 100644--- a/specs/OVERVIEW.md+++ b/specs/OVERVIEW.md@@ -46,6 +46,7 @@ | [Entry to Work Link](#entry-to-work-link) | 2026-09-14 | Done — all 5 tasks implemented 2026-09-14 to 2026-09-16; `make test-quick` green with no new warnings; every phone UI suite green on the branch across three partial `make test-ui` runs plus per-suite runs (the M4Scale trio excepted); `make test-ui-ipad` ran all 25 cases twice with the new case green both times and one different runner-crash failure each time, no single exit 0 (`verification-run.md`) | Smolspec. The work name on the entry detail screen becomes a control that opens the work's detail screen. One navigation-owned route: from a chapter on top of the Works path it drops the chapter and keeps what is beneath the work; from any other tab it shows that one work with nothing beneath it. Plain text stays for an unresolved or blank name and inside the Merge sheet. App-layer only; no Core, schema or repository change. | | [Update Schedule](#update-schedule) | 2026-09-15 | Done — all 30 tasks implemented 2026-09-16; `make test-core` (3,013 tests, zero known issues), `make test-quick`, `make test-ui-ipad` (27/27) green, `make test-performance-m4 RUNS=3` with the ten documented known issues and `todayPublication` at 0.80–0.88 s under a 2 s budget; the owner's device steps in `prerequisites.md` remain | Full spec. A reader-set list of release weekdays on every work under schema V15 with markers `"14"` → `"15"` and the archive at 14/15, set by seven toggles in the editor's Status card and shown on the meta line; the Recent tab becomes **Today**, a week strip over the day's releases and notes with its own typed route stack, inheriting Recent's banners, duplicate sections and search and superseding the 100-row cap; a Works filter finds works with no days. Manual only, no model. | | [Catch-Up Mode](#catch-up-mode) | 2026-09-16 | Done — both phases landed 2026-09-17; four pre-existing UI-test failures recorded in its verification run. Open: the full `make test-performance-m4` has not been run against the fixture with its 66 catch-up works | Smolspec. A fourth reading status, `catchingUp`, for a work the reader is behind on: it shows in a `Catching up` section on Today on the current day and future days whatever its work status or release days, while its release days are parked; no schema bump and no archive generation bump. |+| [Empty Library](#empty-library) | 2026-09-17 | Done | Full spec (T-2118). A `Development`-only Settings row that exports a backup, waits for the share to complete, then deletes every row, Site rows included, through the mirrored per-row path so every device on the account ends up empty and the archive is the way back. Compiled out of `Personal`, the pass included. | | [Share Sheet Character Chips](#share-sheet-character-chips) | 2026-09-18 | In Progress | Smolspec (T-2317). The share sheets' cast text row becomes name-only chips; tapping one opens an inline card with that character's aliases and note, never facts. A cast past two chip rows collapses behind a `+N more` chip (Decision 1). Read-only, no schema change; `FlowLayout` moves into ConstellationKit. Supersedes `share-sheet-characters` Q2 (notes) and Q6 (no pills). |  ---@@ -776,6 +777,23 @@ Smolspec. A reader behind on a story has no reason to wait for its release day,  --- +## Empty Library++**Created:** 2026-09-17 · **Status:** Done — all 21 tasks implemented and reviewed 2026-09-19 (T-2118); Q1–Q47 (Q43 superseded) and Decisions 1–6 in the log. The owner's device arms in `runbook.md` and a quiet-host `make test-performance-m4` are still to run; results so far are in `verification-run.md`. The owner's manual arms are in `prerequisites.md`.++Full spec. A `Development`-only Settings row that exports a backup, waits for a positive completion signal from the sharing surface, and then deletes every row of the live schema, **Site rows included**, through the ordinary mirrored per-row path, so every device on the account ends up empty and importing the archive is the way back (Decisions 1, 2). Children before parents in chunks of 500 under one exclusive lock and the exclusion import holds, swept up to three times because the lock does not fence the mirror; an interrupted run is reported at the next open from its own sidecar and resumed by running the row again. The pending-capture spool and the `ExportOwed/` markers are discarded by a listing taken at the start; the directory defaults are not re-seeded until the next open. The pass is compiled in Core only under the fixture gate, so `Personal` contains neither the row nor the routine (Q22). One named residue: a second device still receiving the deletions can mint a membership row that syncs back, and a second run removes it (Q26).++- [requirements.md](empty-library/requirements.md)+- [design.md](empty-library/design.md)+- [tasks.md](empty-library/tasks.md)+- [decision_log.md](empty-library/decision_log.md)+- [prerequisites.md](empty-library/prerequisites.md)+- [runbook.md](empty-library/runbook.md)+- [verification-run.md](empty-library/verification-run.md)+- [implementation.md](empty-library/implementation.md)++---+ ## Share Sheet Character Chips  **Created:** 2026-09-18 · **Status:** In Progress — tasks 1–4 and 6 implemented 2026-09-19 (T-2317); task 5, the full bar, is open. Q1–Q13 and Decision 1 in the log.
specs/empty-library/decision_log.md Added +274 / -0
diff --git a/specs/empty-library/decision_log.md b/specs/empty-library/decision_log.mdnew file mode 100644index 00000000..64d84226--- /dev/null+++ b/specs/empty-library/decision_log.md@@ -0,0 +1,274 @@+# Decision Log: Empty Library++## Quick Decisions++| ID | Date | Decision | Rationale |+|----|------|----------|-----------|+| Q1 | 2026-09-17 | Full spec workflow, not smolspec | All three routing triggers fire: what "empty" means under mirroring is the reader's to decide (Decision 1), a mirrored deletion is irreversible and touches the additive-only Site rule (Decision 2), and chunked row deletion against store replacement is a contested approach |+| Q2 | 2026-09-17 | Spec name `empty-library` | Names the action the reader takes; `library-reset` and `backup-and-empty` considered |+| Q3 | 2026-09-17 | `Development` builds only, in the Settings Debug disclosure, compiled out of `Personal` on the Run background export row's terms | Owner's pick. The stated purpose is testing reimport; the row can be promoted later if a reader-facing reset is ever wanted |+| Q4 | 2026-09-17 | The backup goes out through the existing share sheet and must complete; cancelling the share sheet aborts the emptying | Owner's pick. Nothing is deleted until the archive has left the app, and no new retention rule is needed for a file kept on disk |+| Q5 | 2026-09-17 | The pending-capture spool and the `ExportOwed/` markers are discarded with the rows | Owner's pick. Empty means empty; owed markers describe rows that no longer exist, and unseen captures are accepted as lost — the archive never held them |+| Q6 | 2026-09-17 | The action does not re-seed the default work types and creator roles; the library holds zero directory rows until the next app open re-mints them | Owner's pick. A literal empty, and it keeps the same-session round trip of Req 5.1 exact: the archive's directories come back under their own UUIDs with nothing seeded beside them to fold over |+| Q7 | 2026-09-17 | A refused or failed export aborts the emptying and shows the refusal as the Export Backup row does | Owner's pick. No backup means no emptying; the reader repairs through Check Library and retries. Proceeding without a backup was offered and declined as the one path with no recovery |+| Q8 | 2026-09-17 | Confirmation is the house dialog naming the entry, work and site counts and the every-device consequence, not a typed word | Owner's pick. Matches the set-aside-capture delete and work-delete dialogs; a typed confirmation is a pattern the app does not have |+| Q9 | 2026-09-17 | Order is confirm → export → share → delete, with no second prompt after the share sheet completes | The dialog already names the backup as the first step and the deletion as what follows; a second prompt after saving would ask twice for one act, which polish-and-export Q50 rejects. The counts are informative (Req 1.2), unlike work deletion's Q28, because "everything" is the contract whatever the count |+| Q10 | 2026-09-17 | The deletion starts only on a positive completion signal from the sharing surface; a cancelled or unreported dismissal is a cancel | Requirements review: today's share wrapper runs one closure on any dismissal and does not say which, so "completed" and "cancelled" were indistinguishable and either nothing would ever delete or a cancel would delete everything. `UIActivityViewController` reports completion and macOS's save panel returns a result, so the wrapper can report it. "Completed" means an activity ran (Copy counts), not that a file was saved, so the story says "handed off" |+| Q11 | 2026-09-17 | Children before parents: rows that refer to other rows are deleted before the rows they refer to, sites last, amended by Decision 3 for a Site's own rules; Req 3.3's "diagnosis" is the per-hostname tuple set the commit gates refuse on | The order bounds every save by the chunk size (deleting a Site first nullifies every entry on it in one save) and leaves an interrupted run holding children without parents, the shape sync already produces. Not for Req 3.3's sake: an entry whose site is gone is `.siteMissing` and a work whose memberships are gone is `.workWithoutMembership`, both tolerated states, and the gates consume only the tuple map (`introducedDiagnosis`). Tolerated states appear transiently between phases, which is what tolerated means (design review) |+| Q12 | 2026-09-17 | An interrupted emptying is reported at the next open on the interrupted-import notice's terms, and every exit path re-fires the deferred triggers | Requirements review: the inline row outcome dies with the process, so without a sidecar an interrupted emptying is silent where an interrupted import is reported. The re-fire is on every path because `confirmImport` only defers the exclusion flag, not the re-fire, and a throwing empty must not leave a deferred pass stranded |+| Q13 | 2026-09-17 | The spool and `ExportOwed/` are removed by a listing taken when the deletion begins; a file that lands after the listing survives | Requirements review: the spool is four directories plus a report, its writers publish by atomic rename with no cross-process lock, and set-aside captures are reader-visible in Settings, so the dialog names them. Deleting the root would race the extension; a cutoff makes Req 3.6 and 3.7 consistent |+| Q14 | 2026-09-17 | Req 3.8 asserts an empty diagnosis set only; which empty state a screen shows is the sync status's business | Requirements review: the "arriving from iCloud" variant is selected by `hasEverImported`, which latches in the sync status file that Req 3.7 leaves untouched, so on a device whose mirror never imported the emptied library shows "arriving" and that is the truth. A presentation override would add state with no defined lifetime |+| Q15 | 2026-09-17 | Req 5.1's equality is archive-to-archive ignoring export metadata, and "dirties nothing" is no moved modification timestamp and an equal archive after the second import; the "no pending changes" half was dropped by Decision 4 | Requirements review: the wire projection unions duplicate rows and synthesises Sites for rowless hostnames, so per-entity row counts would fail on a healthy round trip; the reader's purpose is that the archive survives the trip, which archive equality states directly |+| Q16 | 2026-09-17 | The dialog's counts and the outcome's count are physical rows | Requirements review: work deletion's Q28 counts groups because its contract is a set; "everything" has no set, and the outcome count is rows already |+| Q17 | 2026-09-17 | The session between emptying and relaunch runs with zero directory rows, and capture, the editor and the pickers must tolerate that | Requirements review. Seeding runs at open only; the alternative, telling the reader to relaunch first, is harmless since the defaults seed under fixed UUIDs and the archive's rows upsert over them, but tolerance is the standing rule for a list not yet synced and costs nothing to keep |+| Q18 | 2026-09-17 | The M4 deletion arm is reported with a regression ceiling drawn from its first quiet-host measurement, not the 3 s class ceiling | Requirements review: deleting entries under a live Site pays the `Site.entries` maintenance T-2093 measured, and the 5,000-row cost is not known until measured. The design decides whether the settling pass's detach-first technique is used |+| Q19 | 2026-09-17 | The archive's recovery boundary is the moment the export ran; a record committed between export and completion is deleted and not in the archive | Requirements review: the archive and the deletion cover different moments, so "always recoverable" was false. Stated rather than closed, because closing it would mean holding the library locked across the share sheet |+| Q20 | 2026-09-17 | Completion is local: rows deleted and the deletions in the mirror's history; other devices empty when they next sync | Requirements review: tombstone upload and replica application are asynchronous and unobservable from the app, so the outcome reports what it can know |+| Q21 | 2026-09-17 | Req 3.3's per-chunk property is guaranteed by the phase order and asserted by a validating save strategy in tests; the pass validates once, at the end, as import does | A full `validateStore` after every 500-row chunk over a 5,000-row library would run the whole-library validator ten times for a property the order already guarantees. Work deletion gates per commit because it is one commit; import validates once because it is many |+| Q22 | 2026-09-17 | The pass, its sidecar type and the spool additions compile in Core only under `#if DEBUG \|\| ASTERISM_PERFORMANCE_TESTING`, the fixture gate | Owner's pick over the background-export precedent (pass unconditional, row gated). The `Personal` binary then contains no routine that deletes every row, which is the stricter reading of Req 1.1; tests build in debug and the M4 release run defines the second symbol, so nothing that exercises the pass loses it |+| Q23 | 2026-09-17 | One exclusive lock across the whole pass, with the chunk saves inside it, as `confirmImport` holds | Owner's pick. The locked closure is synchronous, so in-process repository calls queue behind the actor and land after the pass, which is what makes Req 3.6's "committed after it completes survives" true; the share extension, opening cross-process under the two-second lock timeout, gets `libraryBusy` and has already preserved the share to the spool. No reader observes a half-deleted library. Per-chunk release was offered for a live UI mid-pass and declined |+| Q24 | 2026-09-17 | `incoming/` staging names listed at the start are unlinked at the end; nothing else in `incoming/` is touched | A real spool write is a durable write into `incoming/` then an atomic rename into `pending/`, milliseconds apart; a name still present in `incoming/` after a pass that takes seconds is an abandoned staging file. Deleting the directory would race a write in flight |+| Q25 | 2026-09-17 | The emptying gets its own sidecar file and notice rather than reusing the import's | The import notice's remedy is "import the same backup again"; the emptying's is "run Empty Library again". One file with two meanings would need a discriminator and two sentences behind one identifier |+| Q26 | 2026-09-17 | Rows that reach an emptied library from another device's arrival reconcile or its next-open seeding are a named residue, removed by a second run; the pass does not try to prevent them | Design review, both reviewers independently: `MembershipReconciler` mints a fresh-UUID membership for an arriving Entry whose Work has no membership, the receiving device applies tombstones in an order the sender does not control, and the minted row has no tombstone. Reordering the sender's phases cannot fix receiver ordering. The seeded defaults return under fixed UUIDs by design (Q6). Requirements Non-Goals carry the residue |+| Q27 | 2026-09-17 | The pass constructs the spool and the owed-marker directory from its own `LibraryConfiguration`, a first for the repository | The listing must precede the lock and the removal must follow the sweeps, inside one call whose report carries the cleanup verdict (Req 4.2); splitting that across the app model would put the contract's two halves on two actors. Both types are values over `rootDirectory` that the configuration already names |+| Q28 | 2026-09-17 | The staged archive is removed when the pass reaches a terminal state, not when the share completes | Design review: "completed" means an activity ran, and until the rows are gone the staged file is the only local copy the reader can recover if the activity was Copy. The 24-hour scavenge covers a process that dies between |+| Q29 | 2026-09-17 | Once the first save has happened the pass reports rather than throws: `interrupted(phase, reason)` with `rowsDeleted` and `remaining` | Req 4.2 needs the count on failure, and `withLockedContext` rewraps any foreign error into `libraryUnavailable(reason:)`, so a typed error cannot carry it out. Throws are reserved for refusals before anything is deleted |+| Q30 | 2026-09-17 | A sidecar that cannot be written refuses the run | `confirmImport` ignores a sidecar write failure; here the sidecar is the only record of a destructive interruption, and a run with no record of itself is the one that must not start |+| Q31 | 2026-09-17 | After the last phase (the seventh since Decision 3) the phases sweep again, up to three sweeps, until one deletes nothing; a third sweep that still finds rows reports `interrupted` | The lock is process-local and does not fence the mirror, so a row can arrive into an entity whose phase has passed. Three bounds the loop against a device that is receiving a stream; the remedy for more is the second run Q26 already needs |+| Q32 | 2026-09-17 | Only `Site.entries` is detached before deletion; the other inverses are left to `.nullify` until the performance arm says otherwise | The `Site.entries` cost is measured (settling-pass-budget Decision 32); the others are arrays of tens of rows per parent and are unmeasured, and `SiteInverseReachTests` sanctions traversals by measurement, not by assertion. Rules are not detached from their Site for the same reason |+| Q33 | 2026-09-17 | The export, staging, cleanup and refusal wording are extracted from `SettingsBackupModel` into `BackupExportStage`; each row keeps its own state machine over it | Design review: the two rows share the export but not the terminal states (the backup row returns to idle on completion, the empty row proceeds), so one workflow machine would carry a flag the other never sets |+| Q34 | 2026-09-17 | The share dismissal and the share outcome are two synchronous transitions that both leave `.sharing`, order-independent | Design review: a binding whose `set` does nothing leaves `state == .sharing` true until an async handler assigns, and SwiftUI re-presents the surface. iOS writes the binding before `onDismiss`; the Mac's exporter result may precede its binding write; a machine that tolerates both orders needs no assumption about either. Amended by Q43 and, in that half, superseded by Decision 6: the iOS outcome callback is a third event that can follow both |+| Q35 | 2026-09-17 | The drain is fenced by an app-model flag and by awaiting the drain task in flight, not by the repository's exclusion flag | Design review: the pass is synchronous inside the actor, so a drain's commits would queue and land in the emptied library, and its refused attempts count against the record's attempt budget. The flag is set before the pass and the in-flight task is awaited first, as teardown does |+| Q36 | 2026-09-19 | `EmptyLibraryTests` seeds its store by importing the golden backup archive, not the tolerated-state harness the design named | Implementation: it is the one fixture that populates all 17 entities, and the suite proves that before emptying rather than assuming it, which is what Req 3.1 is about. The tolerated-state seeds leave most join tables empty |+| Q37 | 2026-09-19 | The core fixture nils `junkSuffixRule` on every Site outside `.articles` after seeding; the importer divergence behind it is T-2335, and Req 5.1 does not hold for an archive carrying that combination until it lands | Review: the import insert path copies the column verbatim where the update path forces it nil, so an import into an empty library and a second import disagree on that column. The shared-expression fix was written and reverted: the golden archive's only junk rule sits on a `.taught` site, so the fix moves the recorded bytes, and re-recording them is the owner's act |+| Q38 | 2026-09-19 | A re-validation or spool re-listing that fails after the rows are gone is reported as a failed cleanup with the row count, never thrown | Review: Q29 reserved throws for refusals before anything is deleted, but the post-sweep `validateStore` could still throw and lose `rowsDeleted`, and a spool that could not be re-listed read as a clean one. Both now join the cleanup verdict's reasons |+| Q39 | 2026-09-19 | `AppLibraryModel` hands the row `emptyLibraryDependencies()`, not the design's `emptyLibraryModel()` | Implementation: the model is `SettingsView`'s `@State` and has to be built there or it forgets its phase across a re-render, so the app model can only supply the inputs |+| Q40 | 2026-09-19 | `emptyLibraryDependencies()` and `interruptedEmptyNotice` are unconditional declarations with `#if DEBUG` bodies | Implementation: `SettingsScreen` passes both inside an argument list on every build, and an argument cannot be conditionally compiled. Both return nil in `Personal`, where no pass, row or notice exists; the review traced one route to the deletion and none in `Personal` |+| Q41 | 2026-09-19 | The row model's two closures are `@MainActor` and its sentences are pluralised per count | Implementation: the closures call a main-actor `AppLibraryModel`, and the annotation lets a test script them without a lock; "1 rows" is not a sentence the row should show |+| Q42 | 2026-09-19 | Every `EmptyLibrary` log line is `notice`, `error` for an interruption or failure, and the row model logs its own transitions beside the pass's | Review: `debug` lines are hidden in Console by default and not persisted, which `background-export` Q41 already learned, and the runbook's arms are evidenced by these lines. Req 4.3 asks for each phase transition, and the hand-off the pass waits on is one |+| Q43 | 2026-09-19 | The share outcome is reset when a presentation begins and delivered once; a report after the delivery is dropped | Review, blocker: `completionWithItemsHandler` can run after the sheet's dismissal, so a `completed` stored after delivery survived in the modifier's state and the next presentation, swiped away, would have emptied the library with no hand-off. The second run Q26 requires made it reachable. Failing closed costs a retry; failing open costs the library **Superseded by Decision 6**: dropping late reports also dropped the real one |+| Q44 | 2026-09-19 | A failure the pass does not report itself reaches the row as an error category, not a description | Review: the pass reduces a save error to its domain and code because a description can name a record; the inventory failure and a throw from the pass went to the row as `String(describing:)` and now share the export stage's content-free category |+| Q45 | 2026-09-19 | The sharing seam logs under its own category, `DocumentExport`, and says so when it drops a report | Pre-push review: `documentExporter` serves the backup and Markdown exports too, so its lines under `EmptyLibrary` put unrelated shares inside an emptying's trace. A dropped report was silent, and a silently dropped genuine report is how Decision 6's bug would come back |+| Q46 | 2026-09-19 | A run that cannot take the lock clears its sidecar; a run refuses while another bulk operation holds the flag; a chunk that saves without removing its rows is an interruption | Pre-push review. The sidecar is written before the lock and nothing can have been saved when the locked call throws, so keeping it told the reader "some records remain" beside "nothing was deleted", across launches. The flag's `defer` cleared it unconditionally, which would have lowered an import's fence. The chunk loop ended only because deleted rows stop being fetched, and it runs holding the exclusive cross-process lock |+| Q47 | 2026-09-19 | The Export Backup and Markdown export rows still remove their staged file at the sheet's dismissal; that is T-2346, not this branch | Pre-push review: Decision 6 established that the activity can outlive the dismissal, and Q28 keeps this row's file until the pass is terminal, but moving the other two rows' cleanup changes two shipped features and their tests. No lost export has been observed |++## Decision 1: Empty Means Every Device, Through Mirrored Row Deletion++**Date**: 2026-09-17+**Status**: accepted++### Context++Both configurations mirror to CloudKit (`cloudkit-mirroring`), so the library on this device is one replica of an account-wide one. "Empty the library" therefore has more than one possible meaning, and the reader's purpose, testing reimport on a real install, needs the library to stay empty until the archive is imported.++### Decision++Emptying deletes every row through the ordinary change-tracked path, so the deletions mirror and every device sharing the container ends up empty. It is not a device-local operation.++### Rationale++A deletion made through the mirror is the only one that stays made: it reaches every replica and the cloud, and nothing refills it. The reader confirmed this is the meaning wanted. It also reuses everything the app already knows about deleting: per-row deletes, chunked commits, the bulk-operation exclusion import holds, and the diagnosis gate on each commit.++### Alternatives Considered++- **Replace the store file and reset the readiness marker**: fast and local - Rejected because the CloudKit zone still holds every record and syncs it straight back, and because it bypasses change tracking entirely, leaving the mirror's persistent history and the store disagreeing.+- **Purge the CloudKit zone**: `purgeObjectsAndRecordsInZone` empties cloud and local at once - Rejected, as `cloudkit-mirroring` Decision 2 already did: mixed field reports on reliability, and a device offline during the purge is a hazard the app cannot see.+- **Both meanings as separate actions**: - Rejected as doubling the surface and the confirmation copy for a meaning the reader has no use for.++### Consequences++**Positive:**+- The emptied library stays empty on every device until an archive is imported.+- No new deletion mechanism; the work-deletion cascade and the import path's chunking and exclusion are the model.+- Interruption is safe by construction: each chunk commits a valid library and a later run finishes the job.++**Negative:**+- Slow over a large library: every row is deleted individually and every chunk exports. Reported, not budgeted (Verification).+- A second device that is offline during the emptying deletes when it next syncs, and a capture it makes in between survives as the only record in the library.+- Every device on the account is emptied, including one the reader forgot about. The confirmation says so.++---++## Decision 2: Site Rows Are Deleted Too++**Date**: 2026-09-17+**Status**: accepted++### Context++`cloudkit-mirroring` Decision 6 makes reconciliation additive-only: no Site row is ever deleted, because `Site.patterns` and `Site.urlRules` cascade, CloudKit applies one local save as many remote transactions, and a delete landing before a re-parent update destroys every rule for the hostname on the receiving device. No code path in the app deletes a Site row today. An action that empties the library has to say whether that rule binds it.++### Decision++The emptying deletes Site rows, and their cascaded title and URL rules, with everything else. Decision 6 is unchanged for reconciliation; it does not govern a reader action whose purpose is to keep nothing.++### Rationale++Decision 6 protects teaching that a merge intends to keep: the hazard is a delete crossing a concurrent re-parent that was supposed to preserve the rules on a survivor. Here there is no survivor and nothing is being preserved; the cascade deletes rules that are being deleted anyway, so the race Decision 6 avoids cannot cost anything. The reader chose the literal empty, and the reimport test the feature exists for is only honest if the Site designation, name, mode and rules come back from the archive rather than surviving in place. The archive carries them (cloudkit-mirroring Decision 10 applies the designation to matched or absent rows on import).++### Alternatives Considered++- **Keep Site rows and their teaching, delete records only**: keeps Decision 6 untouched - Rejected by the reader: an emptied library that still parses titles is not empty, and the reimport test would not cover site teaching.+- **Keep Site rows stripped of their rules**: honours the letter of Decision 6 - Rejected: it leaves untaught rows with no purpose, and Decision 6's hazard does not apply, so the stripped rows buy nothing.++### Consequences++**Positive:**+- The emptied library is empty by every fetch, including Site, so Req 3.1 is a single invariant.+- Reimport exercises the Site designation path, which is where the `cloudkit-mirroring` Decision 10 bug lived.++**Negative:**+- One code path now deletes Site rows. It must stay the only one, and the SiteReconciler tests that pin "never deletes" must not be loosened to admit it.+- A capture on another device during the emptying can recreate a Site row for its hostname; that row survives as an untaught Site, which is the tolerated state, not damage.++---++## Decision 3: A Site's Rules Are Deleted in the Site's Own Save++**Date**: 2026-09-19+**Status**: accepted++### Context++Q11 ordered the deletion children before parents with sites last, and the design drew that as eight phases with `TitlePattern` and `URLRulePattern` in a phase of their own ahead of the Works, so that the last phase would delete bare Sites. A rule refers to its Site, so the order followed Q11 to the letter.++It breaches Req 3.3. `LibraryValidator.siteTupleIsLegal` accepts a `.taught` Site only with exactly one active title pattern. Deleting that pattern while the Site row survives leaves `(taught, [], [])`, which the validator reports as a `.siteTuple` diagnosis, the one diagnosis that quarantines a hostname and the one Q11 says Req 3.3 is about. The validating save strategy of Q21 caught it on the first run of the implementation.++### Decision++`Site` is deleted before its rule entities, in one last phase. Each Site's chunk deletes that Site's title and URL rules explicitly in the same save. The `TitlePattern` and `URLRulePattern` loops run behind it and remove rules whose Site is already nil. The pass has seven phases, not eight.++### Rationale++The illegal tuple cannot exist if the Site and its rules leave in one commit. Decision 2 already says the rules go with the Site through the cascade; deleting them explicitly beside it changes only that they are counted, which Req 4.2's row count needs. Keeping the two rule entities in the phase list keeps the schema-coverage contract test a comparison of the whole entity set, and gives an orphaned rule a way out.++### Alternatives Considered++- **Leave the rules to the `.cascade` and drop them from the list**: simplest - Rejected because the reported row count would silently exclude every taught rule, the contract test would need an exemption list, and a rule with a nil Site would never be deleted.+- **Demote each Site to untaught before deleting its rules**: keeps the eight-phase order - Rejected because it writes reader-authored state on a deletion path, mirrors that write to every device, and an untaught Site still holding URL rules is its own illegal tuple.+- **Keep the design's order and accept the transient diagnosis**: no code change - Rejected because Req 3.3 forbids it and an interruption at that boundary would leave every taught hostname quarantined.++### Consequences++**Positive:**+- No commit boundary introduces a tuple diagnosis, asserted by the validating save strategy.+- The rules are in the reported count, and the phase list still names all 17 entities.++**Negative:**+- The last phase's save for a chunk of Sites also carries their rules, so it is bounded by the Sites' rule count rather than strictly by the chunk size. Sites are tens of rows and rules a handful each.+- Q11's "rows that refer to other rows are deleted first" no longer holds for the rules, and Req 3.3 carries the exception in its text.++---++## Decision 4: Req 5.1's Second Import Is Asserted as Stored State, Not as a Clean Save++**Date**: 2026-09-19+**Status**: accepted++### Context++Req 5.1 and Q15 asked that a second import of the same archive move no modification timestamp and leave the store with no pending changes, and the design's test recorded `hasChanges == false` at every save. The import upsert writes every scalar column unconditionally; only the site designation and the cover are value-guarded. Every save of a second import therefore reports pending changes while writing back identical values. The assertion cannot pass against the importer as it stands, and the implementation review found the test had dropped it without the requirement changing.++### Decision++Req 5.1 asserts stored state: the archive exported after the second import equals the first, and no record's `modifiedAt` moves. The "no pending changes" clause is removed from the requirement, the design and Q15. Value-guarding the importer is T-2334.++### Rationale++The reader's purpose in Req 5.1 is that emptying is a safe way to test import, and archive equality states that result in full. The clean-save property is real, since a no-op re-import re-exports every row through the mirror, but it is a property of the importer that exists with or without this feature. Guarding the importer's writes touches every record kind's upsert and interacts with the import recency gate (`cloudkit-mirroring` Decisions 2 and 8); that is a change to review on its own, not inside a `Development`-only debug action.++### Alternatives Considered++- **Value-guard the importer's scalar writes in this branch**: keeps the requirement whole - Rejected as scope: a multi-file change to the production import path riding on a feature that does not ship in `Personal`.+- **Keep the requirement and the assertion, under a known issue**: keeps the gap visible in the test run - Rejected because `make test-core` expects zero known issues and the gap is not this feature's to close.+- **Keep the requirement text and assert less**: what the implementation first did - Rejected because a requirement nothing checks is worse than one that says less.++### Consequences++**Positive:**+- The requirement, the design and the test say the same thing.+- The importer's cost has a ticket of its own with the dropped assertion named in it.++**Negative:**+- A no-op re-import still dirties and re-exports every row until T-2334 lands.+- Req 5.1 is weaker than the owner approved; the owner should confirm the amendment.++---++## Decision 5: The Confirmation Is Armed Once, Not Guarded by State++**Date**: 2026-09-19+**Status**: accepted++### Context++The design had `confirm()` proceed only from `.confirming`. SwiftUI runs a confirmation dialog's dismissal before the tapped button's action, so the binding's `set` had already called `cancelConfirmation()` and returned the machine to `.idle` by the time `confirm()` ran. Driven through the real dialog the row did nothing at all, which the UI journey found; the model tests, calling `confirm()` directly, had passed.++The first fix let `confirm()` accept `.idle`. That works, but it moves "the reader saw the dialog" out of the machine and into the one view that calls it, for the only routine in the app that deletes every row.++### Decision++`prepareConfirmation` arms the confirmation with the inventory it counted, and `confirm(_:)` consumes it: it proceeds from `.confirming` or `.idle` only when handed the armed inventory, and disarms before it does anything else. The token is the value the dialog presents, so no second piece of vocabulary exists beside it. `cancelConfirmation` does not disarm, because it runs before the button's action. A second `prepareConfirmation` while one is counting is ignored. The dialog hands its `presenting:` value to the action, as the set-aside-capture delete on the same screen does.++### Rationale++The ordering is SwiftUI's and cannot be changed, so the state at the moment of the tap cannot carry the invariant. A token that only raising the dialog can mint, and that one `confirm` spends, carries it instead: a stray or repeated `confirm` finds nothing to spend. It is the house `presenting:` pattern one layer down, where the value the dialog was built from outlives the state the dismissal cleared.++### Alternatives Considered++- **The binding's `set` does not cancel**: keeps `confirm()` on `.confirming` - Rejected because a tap outside the dialog would leave `.confirming` standing and SwiftUI would present the dialog again, the hazard Q34 records for the share sheet.+- **Accept `.idle` unconditionally**: what the implementation first did - Rejected because nothing in the model then distinguishes a confirmed run from a stray call.+- **A second, post-share confirmation**: a guard at the point of deletion - Rejected by Q9 already: it asks twice for one act.++### Consequences++**Positive:**+- A `confirm()` with no dialog behind it exports nothing and deletes nothing, and a test says so.+- The dialog is back on the screen's own pattern.++**Negative:**+- The machine's states no longer tell the whole story; the arming is a second piece of state that has to survive `cancelConfirmation` and must not survive anything else.+- A reader who dismisses the dialog by tapping outside leaves the confirmation armed until the next `prepareConfirmation` replaces it. Nothing can spend it in between, since only the dialog's button calls `confirm`.++---++## Decision 6: The Share Outcome Answers Its Own Presentation, Whenever It Arrives++**Date**: 2026-09-19+**Status**: accepted++### Context++Q43 closed a blocker: `completionWithItemsHandler` can run after the sheet's dismissal, so a `completed` stored after delivery could answer the next presentation and empty the library on a swipe-to-cancel. Its guard delivered the outcome at the sheet's `onDismiss` and dropped any report that arrived afterwards.++On the owner's phone the row then did nothing: the backup was exported and saved through Save to Files, and no deletion followed. With Save to Files the handler always runs after the dismissal, so the dismissal delivered `.cancelled`, the real `completed` was dropped, and the model removed the staged file and returned to idle without a word. No simulator test could show it, because no test can complete an activity (Req 2.3's positive arm is the runbook's), and the review had rated this side of the guard "safe but annoying". It made the feature unusable through its most obvious hand-off.++### Decision++Presentations are numbered. The sheet's handler carries the number it was presented under; a report for the presentation now up is delivered when it arrives, once, and a report for an older one is dropped. The dismissal delivers nothing. If no report has arrived three seconds after the dismissal, the presentation is answered `.cancelled`. The model already waits in `.shareDismissed` between the two events (Q34).++### Rationale++The hazard was never lateness, it was a report answering the wrong presentation, and a number says which presentation a report belongs to where a clock cannot. Delivering on the report rather than on the dismissal follows the platform's actual order instead of assuming one. The grace wait exists only because a SwiftUI sheet that is swiped away may never call the handler, and something has to return the row to idle.++### Alternatives Considered++- **Keep Q43 and tell the reader to use another activity**: no code - Rejected because Save to Files is the hand-off the feature is for, and whether any activity reports before the dismissal is not documented.+- **Deliver late reports with no numbering**: simplest - Rejected because it reopens the blocker: a late `completed` would answer whatever presentation is up.+- **No grace wait; wait for the handler indefinitely**: no timer - Rejected because a swiped-away sheet would leave the row in its waiting state forever.+- **Present `UIActivityViewController` from UIKit directly, outside a SwiftUI sheet**: one dismissal, one handler, ordered - Rejected as a larger change to an existing platform seam with two other callers, for an ordering the numbering already makes irrelevant.++### Consequences++**Positive:**+- A completed Save to Files starts the emptying, and a stale report still cannot.+- The ledger is a value type driven by five calls, so both hazards are unit tests.+- A notice line says whether the activity's report or the grace period answered, which is the first thing to read when the row misbehaves on a device.++**Negative:**+- A swiped-away sheet holds the row in its waiting sentence for up to three seconds before it returns to idle.+- A report later than three seconds after the dismissal is still reported as cancelled. That costs a retry and never the library.+- Every `documentExporter` caller now logs its outcome, the backup and Markdown exports included, under the seam's own `DocumentExport` category (Q45); the sheet identifier tells them apart.+- The completed arm remains verifiable only by hand, which is how this shipped to a device broken.++---
specs/empty-library/design.md Added +285 / -0
diff --git a/specs/empty-library/design.md b/specs/empty-library/design.mdnew file mode 100644index 00000000..1fe005ba--- /dev/null+++ b/specs/empty-library/design.md@@ -0,0 +1,285 @@+# Design: Empty Library++**Ticket:** T-2118 · Requirements: `requirements.md` · Decisions: `decision_log.md`++## Overview++A `Development`-only Settings row that exports a backup, hands it to the sharing surface, and on a positive completion signal deletes every row of the live schema through the repository's ordinary per-row path, in chunks, under the exclusion import holds. The pass lives in `AsterismCore` behind the fixture gate; the row, its model and the interrupted notice live in the app behind `#if DEBUG`.++## Architecture++### Flow++```+row tap ─► inventory (row counts) ─► confirmation dialog ─┬─ cancel ─► idle+                                                          └─ confirm ─► export ─┬─ refused ─► failed (Check Library route)+                                                                                └─ staged ─► sharing surface ─┬─ cancelled / unreported ─► staged file removed, idle+                                                                                                              └─ completed ─► emptying ─► finished | failed ─► staged file removed+```++`emptying` is one app-model call that awaits any drain in flight, then one repository call. Inside the repository call, in order: exclusion flag up · spool and owed-marker listing · sidecar written (a failure to write it refuses the run) · phases deleted chunk by chunk under one exclusive lock, then swept again until a sweep deletes nothing · full validation · diagnoses published · listed spool files and markers removed and re-listed · sidecar cleared · flag released · deferred triggers re-fired.++### What changes, and where it plugs in++| Piece | Location | Integration point |+|---|---|---|+| Deletion pass | `Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+EmptyLibrary.swift`, whole file inside `#if DEBUG \|\| ASTERISM_PERFORMANCE_TESTING` (Q22) | New extension; uses `withLockedContext(mode: .exclusive)`, `bulkOperationInProgress`, `saveStrategy`, `validateStore`, `setQuarantine` and `refireDeferredReconcile` on `confirmImport`'s terms |+| Interrupted-empty sidecar | `LibraryConfiguration.emptySidecarURL` = `<rootDirectory>/AsterismEmpty.inProgress` | Read at open beside `interruptedImport()` in `AppLibraryModel.bootstrap` |+| Spool inventory and discard | `PendingCaptureSpool.inventory()` / `discard(_:)`, in the pass's file as an extension | Composed from `delete(id:)` (pending), `deleteQuarantined(id:)` (set aside), `deleteRefusal(id:)`, the report path and `incoming/` names |+| Owed markers | existing `ExportOwedMarker.pending()` / `clear(_:)` | Listing-then-clear by name already (background-export Req 2.3); the verdict is a re-listing, because `clear` reports nothing |+| Share completion signal | `documentExporter` in `Asterism/Asterism/Support/PlatformModifiers.swift`; `ShareSheet.swift` | `onCompletion` gains a `DocumentExportOutcome`; iOS reads `completionWithItemsHandler`, macOS reads the `fileExporter` result. Both files are existing platform seams, so no new `#if os(` |+| Export stage shared with the backup row | `BackupExportStage` in `Asterism/Asterism/ViewModels/SettingsBackupModel.swift` | Export, staged URL, cleanup, refusal message and Check Library route extracted from `SettingsBackupModel`, which keeps its own state machine over it |+| Row model | `Asterism/Asterism/ViewModels/EmptyLibraryModel.swift`, whole file `#if DEBUG` | Built by `SettingsView` from `AppLibraryModel.emptyLibraryDependencies()` (Q39); the dependencies are handed to `SettingsView` through `SettingsScreen` like `runBackgroundExport` |+| Row, dialog, share presentation | `SettingsView.swift` | Row inside the Debug disclosure after `thumbnailProbeRow`; dialog and exporter attached to the `List` |+| Interrupted notice | `SettingsView.interruptedImportRow`'s sibling in the Backup section | Sentence from `AppLibraryModel.interruptedEmptyNotice` |+| Drain guard | `AppLibraryModel.drainPendingCaptures(budget:)` | Returns at once while `emptyLibraryInFlight` is set; `emptyLibrary()` awaits `pendingCapturePassTask` before the pass, as teardown does |++### Deletion phases++Children before parents, sites last (Q11), with a Site's rules going in the Site's own save (Decision 3). One generic chunk loop runs each phase; the phase list is the single place the entity set is written, and a contract test compares it with `AsterismSchemaV15.models`.++| Phase | Entity | Detach before delete |+|---|---|---|+| 1 | `Entry` | `Site.entries`, one `removeAll` per Site per chunk (the technique of settling-pass-budget Decision 32, with a second sanctioned entry in `SiteInverseReachTests`) |+| 2 | `WorkSiteMembership` | none |+| 3 | `Character`, `CharacterSuppression` | none |+| 4 | `Place`, `PlaceSuppression`, `WorkLink`, `WorkCredit`, `WorkDistinctPair` | none |+| 5 | `Work` | none |+| 6 | `Series`, `Creator`, `CreatorRole`, `WorkTypeEntity` | none |+| 7 | `Site`, then `TitlePattern`, `URLRulePattern` | none; each Site's chunk deletes that Site's title and URL rules in the same save |++Only `Site.entries` is detached first, because it is the one inverse with a measured cost (8 ms per row over a 5,000-row site). The other inverses are per-Work or per-Site arrays of tens of rows and are left to SwiftData's `.nullify` maintenance; the performance arm decides whether any of them needs the same treatment (Risks). The rules are not a phase of their own ahead of the Works, as this design first had them: a `.taught` Site whose title rule has been deleted while the Site row survives is the illegal tuple `(taught, [], [])`, a `.siteTuple` diagnosis the chunk introduced, which is what Req 3.3 forbids, and the validating save strategy caught it (Decision 3). So a Site's chunk deletes its rules explicitly in the same save, which keeps them in the row count where the `.cascade` alone would not, and the two rule entities keep their own loops behind `Site` for a rule whose Site is already nil, which also keeps every entity in the list the contract test compares.++Req 3.3's "diagnosis" is the per-hostname tuple set, the one every commit gate refuses on through `introducedDiagnosis`. Tolerated states (`.workWithoutMembership` between phases 2 and 5, `.siteMissing` never, since sites go last) appear transiently between chunks and are what "tolerated" means. The order buys two other things: every save is bounded by the chunk size, where deleting a Site first would nullify every entry on it in one save; and an interrupted run leaves children without parents, the shape sync already produces, rather than parents without children.++Chunk loop, per entity:++```+while true:+    rows = fetch(FetchDescriptor<T>(fetchLimit: bulkOperationBatchSize))+    if rows.isEmpty: break+    detach(rows); for row in rows: context.delete(row)+    do { try saveStrategy.save(context) }+    catch { context.rollback(); return .interrupted(phase, rowsDeleted, reason) }+    rowsDeleted += rows.count+```++No sort on the fetch: every row goes, so order within a phase is irrelevant, and a sort would fault a column for nothing. After phase 7 the seven phases run again, up to three sweeps in total, until a sweep deletes zero rows: the lock is process-local and does not fence the mirror, so a row can arrive into an entity whose phase has passed. A third sweep that still finds rows is reported as `.interrupted` with the remaining counts, not looped.++### Pattern extension audit: `documentExporter` callers++| Call site | Needs the outcome | Change |+|---|---|---|+| `SettingsView` backup row (`settings-backup-share-sheet`) | no | `onCompletion: { _ in }`; the binding's `set` stays the dismissal |+| `MarkdownExportShare` (entry and work markdown export) | no | Same |+| `SettingsView` empty-library share (`settings-empty-library-share-sheet`) | **yes** | Binding's `set` calls `model.shareDismissed()` synchronously; `onCompletion` calls `model.handleShareOutcome(_:)` |++### Pattern extension audit: `#if DEBUG` surfaces++| Surface | Gate |+|---|---|+| `EmptyLibraryModel.swift` | whole file, like `DebugActionTriggerModel.swift` |+| `SettingsView.emptyLibraryRow`, its `@State` model, dialog and exporter | `#if DEBUG` blocks, like `backgroundExportRow` |+| `SettingsView.init` parameter `emptyLibrary: EmptyLibraryDependencies?` | unconditional (an init parameter cannot be gated); `EmptyLibraryDependencies` is an unconditional app-target struct of closures |+| `AppLibraryModel.emptyLibrary()`, `emptyLibraryInFlight`, the stored `interruptedEmpty` | `#if DEBUG` |+| `AppLibraryModel.emptyLibraryDependencies()`, `interruptedEmptyNotice` | unconditional declarations whose bodies are `#if DEBUG` and return nil in `Personal`, because `SettingsScreen` passes both in an argument list on every build (Q40) |+| `LibraryRepository+EmptyLibrary.swift`, `InterruptedEmptyReport`, the spool additions | `#if DEBUG \|\| ASTERISM_PERFORMANCE_TESTING` (Q22). `DEBUG` reaches the package under `Development`: `ThumbnailStoreProbe` is gated the same way and called from `SettingsView`, and the pbxproj sets `SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"` on `Development` only |++### Convergence residue++Two things can put rows into a library the pass has emptied, and neither is the pass's to prevent (Q26):++- A second device applies the tombstones as many transactions and runs its arrival reconcile between them. `MembershipReconciler` mints a fresh-UUID membership for an arriving Entry whose Work has no membership, and that row has no tombstone, so it syncs back as an orphan once the Work's own deletion lands.+- Any device's next open re-mints the directory defaults under their fixed UUIDs.++Both are removed by running the row again once the other devices have synced. The finished sentence says other devices empty when they next sync; the runbook says to run it twice when two devices are in play.++## Components and Interfaces++### Core++```swift+// LibraryRepository+EmptyLibrary.swift+public struct EmptyLibraryInventory: Sendable, Equatable {+    public let entries: Int, works: Int, sites: Int      // physical rows (Q16)+    public let waitingCaptures: Int, setAsideCaptures: Int+}++public struct EmptyLibraryReport: Sendable, Equatable {+    public enum Outcome: Equatable { case completed, interrupted(phase: String, reason: String) }+    public let outcome: Outcome+    public let rowsDeleted: Int+    public let remaining: Int                              // non-zero only when interrupted+    public let cleanup: Cleanup                            // .done | .failed(reason: String) | .skipped (interrupted)+}++public struct InterruptedEmptyReport: Codable, Sendable, Equatable {+    public let startedAt: Date+}++extension LibraryRepository {+    public func emptyLibraryInventory() async throws -> EmptyLibraryInventory   // shared lock; row counts plus spool counts+    public func emptyLibrary() async throws -> EmptyLibraryReport+    public func interruptedEmpty() -> InterruptedEmptyReport?+}+```++`emptyLibrary()` contract:++- Throws only before anything is deleted: the gate is not `.multiSite`, the listing fails, the sidecar cannot be written (Q30), or the lock cannot be taken. Everything after the first save is reported, never thrown, so `rowsDeleted` is never lost (Q29); `withLockedContext` rewraps foreign errors into `libraryUnavailable`, which is the other reason a typed error cannot cross it.+- Holds `bulkOperationInProgress` for its whole duration with `defer { bulkOperationInProgress = false }`, exactly as `confirmImport`, and calls `refireDeferredReconcile()` after the locked closure returns, on the reported-interrupted path as well as the completed one. The re-fire inside the `defer`'s scope is what makes the deferred pass find the flag down.+- The pass constructs `PendingCaptureSpool(rootDirectory:clock:)` and `ExportOwedMarker(directory:)` from its own `configuration` (Q27). Both are listed before the sidecar is written and the lock is taken; removal uses that listing (Q13) and the verdict is a re-listing: anything listed and still present after `discard` or `clear` is `cleanup: .failed(reason)`.+- Sidecar written before the first save; cleared after the sweeps end with zero rows, whether or not cleanup succeeded. An `.interrupted` report leaves it in place and skips cleanup: the spool is not discarded while rows remain.+- One `withLockedContext(mode: .exclusive)` around all sweeps (Q23). The closure is synchronous, so in-process repository calls queue behind the actor for the pass's duration; the share extension, which opens with a shared lease under the two-second lock timeout, gets `libraryBusy` and has already preserved the share to the spool, where a later run or drain finds it.+- After the sweeps: `validateStore` → `diagnostics = result`, `setQuarantine(result.quarantineMap())`. On an emptied store both are empty, which is Req 3.8 by construction.+- Idempotent: a run over an empty library deletes zero rows, lists nothing, and reports `completed` with `rowsDeleted: 0`.+- Logs under `subsystem: me.nore.ig.Asterism category: EmptyLibrary`: `started` with the inventory, one line per phase per sweep with its count, `deleted` with the total, `cleanup` with its outcome, `interrupted` with the phase and the error category. The row model logs its own transitions in the same category: export started, sharing, share dismissed, the share outcome, emptying started and the terminal outcome. Counts and reasons only, at `notice` level so Console shows and persists them without Include Debug Messages, with `error` for an interruption or failure (Q42).++`PendingCaptureSpool` additions:++```swift+public struct PendingCaptureInventory: Sendable, Equatable {+    let waiting: [UUID], setAside: [UUID], refusals: [UUID], incomingNames: [String], hasReport: Bool+}+public func inventory() throws -> PendingCaptureInventory+public func discard(_ inventory: PendingCaptureInventory) throws -> PendingCaptureInventory   // what is still present afterwards; empty on success+```++`discard` is non-throwing on a missing file: a record the drain or the reader removed in between is the normal outcome of two passes racing. It throws when the re-listing does, because an empty answer off a failed listing would report a spool that may be untouched (Q38). `incoming/` names listed at the start are unlinked by name; nothing else in `incoming/` is touched (Q24).++### App++```swift+// SettingsBackupModel.swift — extracted, unconditional+@MainActor final class BackupExportStage {+    init(exporter: any BackupExporting)+    func export() async -> Result<URL, ExportRefusal>      // ExportRefusal: message, routesToCheckLibrary+    var stagedFileURL: URL?+    func cleanup()                                          // removes the staged file, idempotent+}+```++`SettingsBackupModel` keeps its states and drives the stage; its wording lives in the stage's `ExportRefusal`, so Req 2.2 holds because both rows read one sentence.++```swift+#if DEBUG+@MainActor @Observable+final class EmptyLibraryModel {+    enum State: Equatable {+        case idle+        case confirming(EmptyLibraryInventory)+        case exporting+        case sharing                                   // stage.stagedFileURL non-nil+        case shareDismissed                            // surface closed, outcome not yet delivered+        case emptying+        case finished(String)+        case failed(message: String, routesToCheckLibrary: Bool)+    }++    init(stage: BackupExportStage,+         inventory: @escaping @MainActor () async throws -> EmptyLibraryInventory,+         empty: @escaping @MainActor () async throws -> EmptyLibraryReport)   // Q41++    func prepareConfirmation() async        // idle | finished | failed → confirming, and arms the confirmation; ignored while a count is in flight; a failure to count → failed+    func cancelConfirmation()               // confirming → idle; does not disarm, because SwiftUI runs it before the button's action (Decision 5)+    func confirm(_ inventory: EmptyLibraryInventory) async   // armed with that inventory, from confirming | idle → exporting → sharing | failed; consumes the arming (Decision 5)+    func shareDismissed()                   // sharing → shareDismissed, synchronous, idempotent+    func handleShareOutcome(_ outcome: DocumentExportOutcome)+                                            // sharing | shareDismissed: .completed → emptying (synchronous), then Task { runPass() }+                                            //                            .cancelled → stage.cleanup(), idle+}+#endif+```++- `shareDismissed` and `handleShareOutcome` both leave `.sharing` synchronously, so `state == .sharing` reads false before SwiftUI re-evaluates the presentation, whichever of the binding write and the outcome callback arrives first. The pair is order-independent: on iOS the binding write precedes `onDismiss`; on the Mac the exporter result may precede the binding write.+- `runPass` awaits `empty()`, then `stage.cleanup()`, then `.finished` or `.failed`. The staged archive outlives the pass (Q28): "completed" means an activity ran, and the file is the only local copy until the pass reaches a terminal state. The 24-hour scavenge covers a process that dies in between.+- Sentences: `Removed N rows. Other devices empty when they next sync.`; with a cleanup failure, `Removed N rows, but some pending captures or markers could not be removed: <reason>.`; interrupted, `Removed N rows; phase "<phase>" failed: <reason>. M rows remain. Run Empty Library again to remove them.`+- The finished and failed arms re-enter through `prepareConfirmation`: a second run is the resume.++`EmptyLibraryDependencies` (unconditional, app target): `stage`, `inventory`, `empty`. `AppLibraryModel.emptyLibraryDependencies()` builds the stage from the same exporter construction `settingsBackupModel()` uses, so both scavenge one staging directory.++`AppLibraryModel.emptyLibrary()`: sets `emptyLibraryInFlight`, awaits `pendingCapturePassTask` (the teardown precedent), calls `repository.emptyLibrary()`, then `refreshDiagnosesAndSnapshots()`, `dismissDrainReport()` and `refreshPendingCaptureSurfaces()` whatever the outcome, and clears the flag. `drainPendingCaptures(budget:)` returns before its watcher bracket while the flag is set; the next foreground activation retries as today.++### View++`emptyLibraryRow` follows `debugActionRow`'s state switch with two extra arms:++| State | Rendering | Identifiers |+|---|---|---|+| idle, confirming | button `Empty Library…`, `trash` glyph, `.destructive` role | `settings-empty-library-run`; dialog `settings-empty-library-confirm` / `-cancel` |+| exporting / sharing / shareDismissed / emptying | `ProgressView` + phase text (`Exporting a backup…`, `Waiting for the backup to be saved…`, `Emptying the library…`) | `settings-empty-library-progress` |+| finished | sentence + button, as `debugActionRow`'s finished arm | `settings-empty-library-result`, `settings-empty-library-run` |+| failed | `backupRow`'s failure arm: message, Try Again, and Check Library when routed | `settings-empty-library-result`, `settings-empty-library-retry`, `settings-empty-library-check-library` |++Dialog: title `Empty the library?`; message names the row counts, the waiting and set-aside captures, that every device on the iCloud account is emptied, and that a backup is exported first and must be handed off before anything is deleted. Attached to the `List` with `presenting:` on the house pattern. The exporter is attached beside the backup row's, bound to `model.state == .sharing`, identifier `settings-empty-library-share-sheet`.++### Share completion signal++```swift+enum DocumentExportOutcome: Sendable, Equatable {+    case completed, cancelled+    init(activityCompleted: Bool)                       // iOS+    init(fileExporterResult: Result<URL, any Error>)    // macOS+}++func documentExporter(isPresented:, file:, identifier:, onCompletion: @escaping (DocumentExportOutcome) -> Void)+```++- iOS: `documentExporter` is a `ViewModifier` holding two pieces of `@State`: an `ExportOutcomeLedger` (which presentation is up and whether it has been answered) and the dismissal's grace `Task`. `ShareSheet` gains `onOutcome: (Bool) -> Void`, installed as `completionWithItemsHandler` on make and on update; the closure captures the presentation number by value and calls back into the modifier, never into the representable struct, which SwiftUI rebuilds. Each presentation is numbered, and the sheet's handler carries the number it was presented under (Decision 6). A report for the presentation now up is delivered when it arrives, before or after the sheet's dismissal, once; a report for an older presentation is dropped. A dismissal with no report yet delivers nothing and starts a three-second grace wait, after which the presentation is answered `.cancelled`. `completionWithItemsHandler` runs after the dismissal on Save to Files, which is why the dismissal cannot be the moment of delivery; a sheet swiped away may never call it, which is why the wait exists. `ShareSheet` installs the handler on update as well as on make, so the newest number wins whatever order SwiftUI builds the sheet's content and runs the presenter's `onChange` in.+- macOS: `fileExporter`'s result maps through `init(fileExporterResult:)`; `onCancellation` delivers `.cancelled`.++### Interrupted notice++`AppLibraryModel` reads `interruptedEmpty()` where it reads `interruptedImport()` and exposes `interruptedEmptyNotice`: `Emptying the library did not finish. Some records remain. Run Empty Library again to remove them.` `SettingsView` renders it as a second row on `interruptedImportRow`'s shape, identifier `settings-interrupted-empty-notice`. The sidecar is re-read after every outcome of `emptyLibrary()`, so the notice is right after a completed run, which removed it, and after an interrupted one, which left it; `bootstrap` reads it at open.++## Error Handling++| Failure | Where | Effect |+|---|---|---|+| Inventory fetch throws | `prepareConfirmation` | `.failed` with the repository message; nothing exported |+| Export refuses (torn, unrepresentable, references arriving) | `confirm` | `.failed`, Check Library route on torn; nothing deleted (Req 2.2) |+| Share cancelled or unreported | `handleShareOutcome` | staged file removed, `.idle` (Req 2.3) |+| Sidecar cannot be written | pass, before the lock | throws; nothing deleted; `.failed` names it (Q30) |+| Chunk save throws | pass | that chunk rolled back; `.interrupted(phase, reason)` with `rowsDeleted` and `remaining`; sidecar stays; cleanup skipped; flag released and deferred pass re-fired (Req 3.4, 4.2) |+| Third sweep still finds rows | pass | `.interrupted("sweep", …)` with `remaining`; sidecar stays |+| Process dies mid-pass | — | sidecar found at next open → notice; library holds the undeleted rows |+| Cleanup leaves a listed file or marker | pass | `cleanup: .failed(reason)`; sidecar cleared; `.finished` sentence names it (Req 4.2) |+| `empty()` throws before the first save | `runPass` | `.failed` with the message; staged file removed |++## Risks and Assumptions++- Risk: with only `Site.entries` detached first, the `.nullify` maintenance on `Work.entries`, the two membership inverses and `Work.characters` may still dominate the pass over the M4 fixture | Verify: the performance arm's per-phase log lines on its first quiet-host run | If wrong: detach that inverse the same way, add its sanctioned entry with the measurement, and record the decision.+- Assumption: SwiftData removes an `.externalStorage` blob's file when the owning row's deletion is saved | Verify: the manual run compares the store directory's size on disk before and after emptying a covered fixture | If wrong: recorded in the verification run; Non-Goals already place byte reclamation outside the feature.+- Risk: the actor is blocked for the pass's duration, so a tab refresh that was queued surfaces after the pass with rows fetched before it | Verify: the manual `Development` run watches Recent and Today during and after the pass | If wrong: `refreshDiagnosesAndSnapshots()` after the pass already republishes; add a snapshot generation bump if a stale publish lands after it.+- Assumption: three sweeps are enough to drain rows the mirror delivers during the pass | Verify: the manual two-device arm, with the second device online during the run | If wrong: the third sweep's `.interrupted` report is the signal, and the remedy is the second run the residue already needs.++## Testing Strategy++**Core (`make test-core`)**, new suite `EmptyLibraryTests` over a store seeded by importing the golden backup archive, the one fixture that populates all 17 entities (Q36), plus one contract test in `ModelContractTests`:++| Requirement | Test |+|---|---|+| 3.1, 3.9 | After `emptyLibrary()`, a fetch of each of the 17 entities returns zero rows; directories included |+| 3.3 | A test `RepositorySaveStrategy` that runs `LibraryValidator.validate(context:)` after every save and records any tuple diagnosis absent before the chunk; asserts none |+| 3.4 | A save strategy that throws on the Nth save: the report is `.interrupted` with `rowsDeleted == (N-1) × chunk` and `remaining > 0`, the store reopens, `interruptedEmpty()` is non-nil, a second run reaches zero rows and clears the sidecar |+| 3.5 | On `reconcileDefersDuringImportAndReFires`' shape (`setBulkOperationInProgressForTesting`, then a deferred `reconcileAfterSync`): after both a completing and an interrupted run, `reconcileDeferred` is false, `isBulkOperationInProgress()` is false and the deferred pass ran |+| 3.6, 3.7 | Spool seeded with waiting, set-aside and refused records, a report, and owed markers; a save strategy that on the first save writes one record file into `pending/` and one marker into `ExportOwed/` by path, as the extension does; everything listed is gone and the two late files survive |+| 3.8 | A store seeded with a tolerated-state diagnosis: `diagnostics` and the quarantine map are empty afterwards |+| 3.9 | The work editor model over zero work types and roles drafts `other` and an empty role list; a capture over a store with zero directories commits |+| 4.2 | A spool whose directory is made unremovable: `cleanup == .failed`, rows still zero, sidecar cleared |+| 5.1 | Export → empty → import → export: the two archives' payloads are equal with `exportedAt` and the file name masked; after a second import the exported payload is still equal and no `modifiedAt` moves (Decision 4; the clean-save property is T-2334) |+| sweeps | A save strategy that inserts an Entry into the context on the last phase's save: the second sweep deletes it, the third finds nothing, and the report is `completed` |+| idempotence | A second `emptyLibrary()` over the emptied store reports `completed` with `rowsDeleted: 0` |+| coverage | `ModelContractTests`: the phase list's entity names as a set equal `AsterismSchemaV15.models`' names |+| App model | `EmptyLibraryModelTests`: transitions for cancel, refusal (routes on torn), share cancelled, dismissal-then-outcome and outcome-then-dismissal both reaching `emptying` exactly once, pass interrupted → failed with the counts, staged file kept until terminal |+| Share outcome | `DocumentExportOutcome`'s two initialisers are pure and unit-tested; `ExportOutcomeLedger` is driven directly: a report during the presentation, a report after the dismissal, a silent dismissal timing out, and a late report that must not answer the next presentation. Each platform's handler wiring is the manual arm, and it is the part that broke on a phone (Decision 6) |++**Performance**, new suite `M4EmptyLibraryPerformanceTests`, one arm `empty-library-m4`: three samples, each over a freshly seeded covered M4 fixture, `reportPerformance` with `expectWithinCeiling` outside any known-issue block. The first commit asserts a provisional 60 s ceiling; the verification-run commit replaces it with the band the first quiet-host run measured (Q18). Adds three seeds and three passes to the M4 target's wall time; the number is recorded there too.++**UI (`make test-ui`)**, new `EmptyLibrarySettingsUITests` on the `Development` simulator, `expandSettingsDebug` then the row:++- `seeded-characters`: tap → dialog shows counts → cancel → row idle and Recent unchanged.+- `seeded-characters`: tap → confirm → `settings-empty-library-share-sheet` appears → dismiss → row idle and Recent unchanged (the cancel arm of Req 2.3).+- `seeded-tolerated-tornEntryGroup`: tap → confirm → `settings-empty-library-result` names the refusal and `settings-empty-library-check-library` is present.++**Manual**, `Development` installs under the device-run rule: the completed-share arm on the phone and on the Mac, a second device online during the run and its state after syncing, the second run that clears the residue, and the store size on disk before and after. Recorded in `verification-run.md`.
specs/empty-library/implementation.md Added +138 / -0
diff --git a/specs/empty-library/implementation.md b/specs/empty-library/implementation.mdnew file mode 100644index 00000000..6cddf38e--- /dev/null+++ b/specs/empty-library/implementation.md@@ -0,0 +1,138 @@+# Implementation: Empty Library++Written for the pre-push review of `T-2118/empty-library` on 2026-09-19. It explains what shipped at three levels and then checks the result against `requirements.md`. The reasons behind each choice are in `decision_log.md`; the measurements are in `verification-run.md`.++## Beginner Level++### What This Does++Asterism keeps a library of things you have read: entries, the works they belong to, the sites they came from. Until now there was no way to get back to an empty library. Import only ever adds, and deleting is one record at a time. That made it impossible to test "import my backup into an empty library" on a real install.++This change adds an **Empty Library** row to Settings, in the Debug section, in `Development` builds only. Tapping it:++1. counts the library and asks you to confirm, naming how much will go and that every device on your iCloud account is emptied;+2. exports a backup file;+3. shows the share sheet so you can save that backup somewhere;+4. only after the share sheet reports that you really did hand the file off, deletes every row;+5. tells you how many rows it removed.++Cancel at any point before step 4 and nothing is deleted.++### Why It Matters++The backup is the only way back, so the order is the whole point: nothing is deleted until the backup has left the app. If the app is killed halfway through the deletion, the library still opens, Settings says the emptying did not finish, and running the row again finishes the job.++The shipped app (`Personal`) contains none of this. Neither the row nor the code that deletes everything is compiled into it.++### Key Concepts++- **Mirroring.** The library syncs through iCloud. Deleting rows the ordinary way means the deletions sync too, so every device ends up empty. Replacing the database file would empty one device and iCloud would fill it straight back up.+- **Chunks.** Rows are deleted 500 at a time, saving after each batch. Every save leaves a library the app can still open, which is what makes an interruption safe.+- **Children before parents.** An entry belongs to a work and a site. Entries are deleted first, then the things they pointed at. Deleting a site first would leave thousands of entries pointing at nothing in a single save.+- **Sidecar.** A small marker file written before the first deletion and removed when the library is empty. If it is still there at the next launch, the last emptying was interrupted.+- **Share sheet outcome.** iOS tells the app afterwards whether you completed a share or cancelled it. The emptying waits for "completed". On a phone that answer can arrive *after* the sheet has closed, which caused the one bug found on a real device.++---++## Intermediate Level++### Changes Overview++Core (`Packages/AsterismCore`):++- `LibraryRepository+EmptyLibrary.swift`, whole file inside `#if DEBUG || ASTERISM_PERFORMANCE_TESTING`: `emptyLibraryInventory()`, `emptyLibrary()`, `interruptedEmpty()`, the report types, and `PendingCaptureSpool.inventory()` / `discard(_:)`.+- `LibraryConfiguration.emptySidecarURL`; `PendingCaptureSpool.paths` and `files(in:)` widened to `internal` for the extension above.+- Tests: `EmptyLibraryTests` (19), one schema-coverage contract test, a second sanctioned entry in `SiteInverseReachTests`, and the `empty-library-m4` performance arm.++App (`Asterism/Asterism`):++- `EmptyLibraryModel` (`#if DEBUG`): the row's state machine and every sentence it shows.+- `BackupExportStage`, extracted from `SettingsBackupModel`: export, staging, cleanup and refusal wording, shared by the backup row and this one.+- `documentExporter` in `PlatformModifiers.swift` now reports a `DocumentExportOutcome`, through `ExportOutcomeLedger` on iOS and the `fileExporter` result on the Mac. `ShareSheet` installs the completion handler.+- `AppLibraryModel`: `emptyLibrary()`, the drain fence, the interrupted notice, `emptyLibraryDependencies()`.+- `SettingsView`: the row, the confirmation dialog, the share presentation, the interrupted notice row.++### Implementation Approach++**The pass.** Seven phases run through one generic chunk loop: entries, memberships, characters, work attachments, works, directories, then sites with their rules. Each chunk is `fetchLimit` 500 with no sort, `context.delete` per row, one `saveStrategy.save`. The phase list is the single place the entity set is written, and a contract test compares it with the schema's models, so a new entity cannot silently survive an emptying. The whole thing runs inside one exclusive `withLockedContext`, with `bulkOperationInProgress` raised and the deferred reconcile re-fired on every exit, exactly as `confirmImport` does. After the last phase the phases sweep again, up to three times, because the lock does not fence the iCloud mirror and a row can arrive into a phase that has passed.++**Reporting instead of throwing.** Throws are reserved for refusals before anything is deleted: a non-`multiSite` store, an unwritable sidecar, another bulk operation in flight, a lock that cannot be taken (which also clears the sidecar). Once the first save has happened the pass returns a report: `completed` with a cleanup verdict, or `interrupted(phase, reason)` with the rows removed and remaining. Reasons carry an error's domain and code, never its description, because a description can name a record.++**The spool.** Pending captures and `ExportOwed/` markers are listed before the sidecar and the lock, and removed after the sweeps by that listing. A file the share extension lands mid-pass is not in the listing and survives.++**The row.** `EmptyLibraryModel` is a plain `@Observable` state machine: idle, confirming, exporting, sharing, shareDismissed, emptying, finished, failed. Two details are load-bearing. First, the confirmation is a one-shot token: SwiftUI runs a dialog's dismissal before the button's action, so by the time `confirm` runs the state is already back to idle; `prepareConfirmation` arms the model with the inventory it counted and `confirm(_:)` proceeds only when handed that same value, consuming it. Second, the share's dismissal and the share's outcome are two separate synchronous transitions that both leave `.sharing`, in either order, reaching `.emptying` exactly once.++**The share outcome.** `ExportOutcomeLedger` numbers each presentation. The sheet's handler captures the number it was presented under. A report for the presentation now up is delivered when it arrives, once; a report for an older presentation is dropped; a dismissal with no report starts a three-second wait that ends in `cancelled`. It is a value type driven by four calls, so both hazards are unit tests.++### Trade-offs++- **Row deletion over store replacement or a CloudKit zone purge.** Slower (about 0.8 ms per row), but it is the only empty that stays empty on every device, and it reuses the deletion and chunking the app already trusts.+- **Site rows are deleted too**, the one sanctioned exception to "no `Site` row is ever deleted". That rule protects teaching a merge intends to keep; here nothing is kept.+- **A Site's rules go in the Site's own save**, not in a phase ahead of the works as first designed. A taught Site that outlives its title rule is an illegal tuple, a diagnosis the chunk itself introduced. A validating save strategy in the tests caught it.+- **Fail closed on the share outcome.** A report later than three seconds after the dismissal is treated as cancelled. That costs a retry and never the library.+- **Req 5.1 was weakened.** "A second import leaves no pending changes" was dropped: the importer writes every scalar unconditionally, which is the importer's defect (T-2334), not this feature's.+- **The row's model lives in the view's `@State`.** Leaving Settings mid-pass loses the final sentence, though not the pass. Hoisting it into `AppLibraryModel` was judged not worth it for a debug row.++---++## Expert Level++### Technical Deep Dive++**Commit boundaries.** Req 3.3 asks that no chunk introduce a diagnosis. The only diagnosis that quarantines is `.siteTuple`, and `LibraryValidator.siteTupleIsLegal` accepts `.taught` only with exactly one active title pattern. Hence Decision 3: `deleteSiteRules` removes a Site's rules in the Site's chunk, and the two rule entities keep their own loops only for rules whose Site is already nil, which also keeps the contract test a whole-set comparison. `.workWithoutMembership` between phases 2 and 5 is a tolerated state and never reaches a commit gate. `.siteMissing` cannot occur because entries go first. The pass validates once, at the end, as import does; per-chunk validation is asserted in tests only.++**Inverses.** Only `Site.entries` is detached before deletion, one `removeAll` per Site per chunk matched by `ObjectIdentifier` (duplicate groups share a UUID). That rewrite is quadratic in a Site's entry count over the chunk size: 10 rewrites over 5,000 entries, 100 over 50,000. Accepted, because clearing the array in one save is the unbounded save the phase order exists to avoid. The measured split over 7,009 rows is entries 71.6%, memberships 23.8%, works 4.5%. Works are the cheapest per row because every parent's inverse array is already empty when the parent goes. Why memberships cost 5.3 times works per row is not known; an earlier note blamed a validating save strategy that exists only in tests.++**Progress and fences.** The chunk loop terminates because deleted rows stop being fetched, so it now checks that each saved chunk shrank the table and reports `no progress in <Entity>` otherwise, since it runs holding the exclusive cross-process lock. The app-level drain fence is separate from the repository's flag: the pass is synchronous inside the actor, so a drain's commits would queue and land in the emptied library; `emptyLibrary()` awaits a drain in flight and turns new ones away before they touch the watcher bracket.++**The outcome ordering, which is the part that broke.** `completionWithItemsHandler` is not ordered against a SwiftUI sheet's `onDismiss`. The first guard (Q43) re-armed the outcome per presentation and dropped any report after delivery, to stop a stale `completed` answering the next presentation and emptying the library on a swipe-to-cancel, a sequence the residue's required second run makes reachable. On a phone, Save to Files reports after the dismissal every time, so the guard dropped the real report and the row did nothing. Decision 6 replaces lateness with identity: reports carry their presentation's number. One wrinkle remains reasoning rather than evidence: the sheet's content may be built before `onChange(of: isPresented)` numbers the presentation, so `ShareSheet` re-installs the handler in `updateUIViewController` and the rebuild that `present()` causes supplies the right number. A dropped report now logs a notice line under `category:DocumentExport`, so that failure would be visible rather than silent.++**Gating.** Core is gated with the fixtures, so the M4 release build has the pass and `Personal` does not. In the app, `SettingsScreen` passes `interruptedEmptyNotice` and `emptyLibraryDependencies()` inside an argument list on every build, so those two are unconditional declarations with `#if DEBUG` bodies returning nil. The `Personal` Mac and iOS binaries hold zero `EmptyLibrary` strings.++### Architecture Impact++- `documentExporter`'s contract changed for every caller: `onCompletion` now fires on the activity's report, or three seconds after the dismissal, not at the dismissal. The backup and Markdown export rows ignore the outcome and still clean up on the binding's `set`, which Decision 6 shows is too early (T-2346).+- `BackupExportStage` is the one place export staging and refusal wording live. The two rows hold separate stages over one staging directory; only the backup row's stage scavenges.+- The repository constructs a `PendingCaptureSpool` and `ExportOwedMarker` from its own configuration for the first time, so that the listing, the removal and the cleanup verdict sit inside one call.+- The "never delete a `Site`" rule now has exactly one exception, written into `CLAUDE.md`. `SiteReconciler`'s tests must not be loosened to admit it.+- Four gating idioms coexist in one feature, each justified where it stands.++### Potential Issues++- **Residue from other devices.** A device still receiving tombstones can mint a membership for an arriving entry whose work is gone, and next-open seeding re-mints the default directories. A second run removes them. The second run's dialog reads zero, because it counts entries, works and sites only; the finished sentence's row count is the evidence.+- **Activities that leave the app** suspend it while the grace wait runs on a continuous clock, so the hand-off may be reported as cancelled on return. Safe, retryable, documented in the runbook.+- **The macOS arm has no timeout.** It relies on `fileExporter` always calling back.+- **A swiped-away sheet** holds the row in its waiting sentence for up to three seconds.+- **`MarkerGenerationFifteenTests`** failed once in the review's run on its second open and passed on every repeat; not this branch's, recorded in the verification run.+- **Unverified by any test:** the iOS delivery wiring itself (the numbering on `onChange`, the grace `Task`). The UI journey that dismisses the sheet is green with or without the fix. The runbook is the only net for it.++---++## Completeness Assessment++Checked against `requirements.md`, with the pre-push spec review's walk of every criterion as the evidence.++### Fully implemented++- **Req 1 (the row, the dialog, cancel).** `Development`-only row, house confirmation dialog with physical row counts, cancel writes nothing. Absence from `Personal` verified on both platforms' binaries.+- **Req 2 (backup first).** Export through the shared stage, refusals shown as the backup row shows them with the Check Library route when offered, deletion only on a positive completion signal, no second prompt. 2.3's positive arm is confirmed on the owner's phone.+- **Req 3 (everything goes, through the mirror).** All 17 entities, change-tracked per-row deletion, chunked with valid boundaries (as amended by Decision 3), interruption reported at next open and finished by a second run, reconcile and drain fenced and re-fired, spool and owed markers removed by listing, empty diagnoses afterwards, no re-seeding, completion stated as local.+- **Req 4 (observable).** Phase shown and second tap refused, including the wait after the surface closes; three outcome sentences with row counts and content-free reasons; every transition logged at `notice` with counts and reasons only.+- **Req 5 (reimport restores).** Export, empty, import, export yields equal archives; a second import moves no stored value and no `modifiedAt` (as amended by Decision 4).++### Partially implemented++- **Req 5.1 as originally approved.** The "no pending changes" clause is dropped, not met. Decision 4 records it and T-2334 carries it. The owner should confirm the amendment.+- **Req 5.1 for one archive shape.** An archive with a junk-suffix rule on a non-articles site imports differently into an empty library than over existing rows (T-2335). The test fixture normalises that column (Q37).++### Missing++Nothing the requirements ask for is absent from the code. What is missing is verification that only devices can give:++- Runbook steps 3 to 6: the second device after syncing, the second run that clears the residue, the Mac's save panel arm, the store size before and after, and the stale-completion check. Step 2's completed hand-off is confirmed.+- No step or test produces the interrupted-emptying notice row on screen; its sentence and its sidecar are tested, the row is not.+- A quiet-host `make test-performance-m4`, which would also settle whether the arm's 16 s ceiling can come down to 12 s.++### Divergences from the design++All are recorded: seven phases instead of eight (Decision 3), the golden archive as the test seed (Q36), `emptyLibraryDependencies()` instead of `emptyLibraryModel()` (Q39), the gating shape (Q40), the one-shot confirmation (Decision 5), the numbered share outcome (Decision 6, superseding Q43), `notice`-level logging and the `DocumentExport` category (Q42, Q45), and the three refusals added by the pre-push review (Q46). The explanation above surfaced no behaviour that the decision log does not account for.
specs/empty-library/prerequisites.md Added +7 / -0
diff --git a/specs/empty-library/prerequisites.md b/specs/empty-library/prerequisites.mdnew file mode 100644index 00000000..4723957c--- /dev/null+++ b/specs/empty-library/prerequisites.md@@ -0,0 +1,7 @@+# Prerequisites for Empty Library++These steps need the owner. Everything else, host runs included, is in `tasks.md`.++## After Implementation++- [ ] Run `runbook.md` (written by task 21) on `Development` installs: the completed-share arm on the phone and on the Mac, a second device online during the run, the second run that clears the residue, and the store size on disk before and after. Nothing mirrors on the simulator or the host, and the share sheet's completed arm cannot be driven from a UI test, so these are the only verification of Req 2.3's positive signal, Req 3.10 and the design's last three risks. `Development` only; the feature does not exist in `Personal`.
specs/empty-library/requirements.md Added +91 / -0
diff --git a/specs/empty-library/requirements.md b/specs/empty-library/requirements.mdnew file mode 100644index 00000000..f6ded863--- /dev/null+++ b/specs/empty-library/requirements.md@@ -0,0 +1,91 @@+# Requirements: Empty Library++**Ticket:** T-2118++## Introduction++There is no way to return the library to nothing: import never deletes (cloudkit-mirroring Decision 2), and deletion is otherwise one record at a time. That makes flows such as "import this archive into an empty library" impossible to exercise on a real install. This feature adds an Empty Library action to `Development` builds that exports a backup, waits for the reader to hand it off, and then deletes every row through the ordinary mirrored path, so every device on the account ends up empty and the archive is the way back.++Reference: `specs/cloudkit-mirroring/` (Decision 2 import never deletes; Decision 6 no Site row is deleted by reconciliation; Decision 10 Site designation on import; Q46 bulk-operation exclusion), `specs/polish-and-export/` (the backup exporter and the Export Backup row), `specs/background-export/` (the `Development`-only Settings trigger and the `ExportOwed/` marker), `specs/pending-capture-queue/` (the spool this feature discards), `specs/bugfixes/settling-pass-budget/` (the cost of deleting entries under a live Site).++## Non-Goals++- Shipping the action in `Personal`. It is compiled out of that configuration, as the other debug rows are.+- A device-local empty. Replacing the store file empties one device and the cloud refills it; the only empty that stays empty is the account-wide one.+- Purging the CloudKit zone. Decision 2 of `cloudkit-mirroring` rated `purgeObjectsAndRecordsInZone` unreliable and nothing here changes that.+- Undo. The archive exported first is the recovery path, and import into the emptied library is how it is used.+- Emptying without a backup. A refused or cancelled export means nothing is deleted.+- A selective empty: by site, by date, by record kind.+- Re-creating the default work types and creator roles inside the action. The next app open on any device mints them under their fixed UUIDs, as it does for a fresh install, and rows minted on two devices converge by UUID rather than duplicate.+- Removing the store file, the readiness marker or the sync status record. Rows are deleted; the store and the markers that describe it stay. On a device whose mirror has never imported, the emptied library shows the "arriving from iCloud" empty state, because that is what the sync status says.+- Reclaiming the bytes behind deleted rows. Completion means zero rows; the store file and its external-storage sidecar are SwiftData's to compact.+- Backing up the pending-capture spool. The archive holds library records only, and the spool is discarded.+- Proving that the deletions reached CloudKit or another device. Host tests do not mirror; propagation is the manual two-device arm.+- Preventing rows that another device puts back while it is still receiving the deletions: its arrival reconcile can mint a membership row for an entry whose work is already gone, and that row syncs in as an orphan. A second run removes it. Completion under [3.10](#3.10) is local for this reason too.+- A background or share-extension path. The action runs in the foreground app only.++---++### 1. The Reader Can Empty the Library from Settings++**User Story:** As the reader on a `Development` build, I want an Empty Library action in Settings, so that I can return the library to nothing and test flows such as reimporting an archive.++**Acceptance Criteria:**++1. <a name="1.1"></a>WHERE the build defines `DEBUG`, the Settings Debug disclosure SHALL show an Empty Library row; a `Personal` build SHALL contain neither the row nor the code behind it, matching the existing Run background export row.  +2. <a name="1.2"></a>WHEN the row is tapped THEN a confirmation dialog SHALL be presented on the terms of the existing set-aside-capture delete confirmation, naming the number of entry, work and site rows that will go, that pending and set-aside captures are discarded, that every device on the iCloud account is emptied, and that a backup is exported first. The counts are the row counts at presentation and are informative: the deletion removes whatever the library holds when it runs.  +3. <a name="1.3"></a>WHEN the reader cancels the dialog THEN nothing SHALL be exported, written or deleted.  ++### 2. A Backup Leaves the App Before Anything Is Deleted++**User Story:** As the reader, I want the emptying to export a backup and wait until I have handed it off, so that the library is recoverable from the archive.++**Acceptance Criteria:**++1. <a name="2.1"></a>WHEN the reader confirms THEN the system SHALL export a backup archive and present it on the sharing surface before any row is deleted; the archive SHALL be the one the existing Export Backup row would produce over the same library, so the existing import accepts it.  +2. <a name="2.2"></a>IF the export refuses or fails THEN the system SHALL delete nothing and SHALL show the refusal as the Export Backup row shows it, including the Check Library route where the refusal offers one.  +3. <a name="2.3"></a>The deletion SHALL begin only on a positive completion signal from the sharing surface: an activity that reports it completed on iOS, a save that reports success on macOS. A dismissal that reports cancellation or reports nothing SHALL delete nothing, SHALL remove the staged archive as the Export Backup row does, and SHALL return the row to idle.  +4. <a name="2.4"></a>WHEN the completion signal arrives THEN the deletion SHALL begin without a further prompt.  +5. <a name="2.5"></a>The archive holds the library as it was when the export ran. A record committed after the export began and before the deletion completed is deleted and is not in the archive.  ++### 3. Everything Goes, Through the Mirror++**User Story:** As the reader, I want the emptied library to be empty on every device and to stay empty, so that what I see afterwards is what an archive is imported into.++**Acceptance Criteria:**++1. <a name="3.1"></a>The deletion SHALL remove every row of every entity in the live schema, Site rows and their title and URL rules included, so that afterwards a fetch of each entity returns zero rows.  +2. <a name="3.2"></a>Every deletion SHALL be made through the change-tracked path the app's other deletions use, so that it reaches every device mirroring the same container; no store-level batch delete and no store file replacement.  +3. <a name="3.3"></a>The deletion SHALL commit in chunks, deleting rows that refer to other rows before the rows they refer to (entries, memberships, links, credits, characters, places and their suppressions before works, series, creators, roles, work types and sites, sites last), except that a Site's title and URL rules SHALL be removed in the same commit as the Site row (Decision 3), and every commit boundary SHALL leave a library the app can open and validate without a diagnosis it did not have before the chunk.  +4. <a name="3.4"></a>IF the deletion is interrupted, by the app terminating or a chunk failing to save, THEN the library SHALL remain openable holding the rows not yet deleted, the next app open SHALL report the interrupted emptying in Settings on the interrupted-import notice's terms, and a later Empty Library SHALL remove the remaining rows and clear the notice.  +5. <a name="3.5"></a>WHILE the deletion runs no reconcile pass, duplicate scan or pending-capture drain SHALL run and a sync arrival SHALL be deferred; whether the deletion completes or fails, triggers deferred during it SHALL re-fire afterwards, as they do after an import.  +6. <a name="3.6"></a>The deletion SHALL remove what it finds: a capture the share extension commits while it runs MAY be deleted with the rest, and one committed after it completes SHALL survive.  +7. <a name="3.7"></a>WHEN the deletion completes THEN the pending-capture spool's incoming, pending, set-aside and refusals directories and its report, and the `ExportOwed/` markers, as listed when the deletion began, SHALL be removed; a file that lands after that listing SHALL survive and drain on the next open. The store file, the readiness marker and the sync status record SHALL be untouched.  +8. <a name="3.8"></a>WHEN the deletion completes THEN the published diagnosis set SHALL be empty for every hostname, including hostnames that had entries but no Site row.  +9. <a name="3.9"></a>The action SHALL NOT re-create the default work types and creator roles; a library emptied in this session holds zero directory rows until the next app open, and WHILE the directories are empty, capture, the work editor and the type and role pickers SHALL work as they do with a list not yet synced.  +10. <a name="3.10"></a>Completion means every row is deleted locally and the deletions are committed to the mirror's history; another device is emptied when it next syncs, and the outcome under [4.2](#4.2) SHALL say so.  ++### 4. The Action Is Observable++**User Story:** As the reader, I want to see which phase the emptying is in and how it ended, so that I know when it is safe to import and can tell an interruption from a completion.++**Acceptance Criteria:**++1. <a name="4.1"></a>WHILE the export, share or deletion is in progress the row SHALL show that phase and SHALL NOT accept a second tap. The share phase lasts until the surface's outcome is delivered, which may be up to three seconds after the surface closed (Decision 6).  +2. <a name="4.2"></a>WHEN the deletion completes, completes with the cleanup under [3.7](#3.7) failing, or fails THEN the row SHALL report which, with the number of rows removed and the reason for a failure, in the way the Run background export row reports its outcome.  +3. <a name="4.3"></a>Each phase transition and the outcome SHALL be logged under one named category with counts and reasons only; no reader content.  ++### 5. Reimport Restores the Library++**User Story:** As the reader, I want importing the archive I just saved to bring the library back exactly, so that emptying is a safe way to test import.++**Acceptance Criteria:**++1. <a name="5.1"></a>WHEN the archive exported under [2.1](#2.1) is imported into the library emptied in the same session THEN an archive exported afterwards SHALL equal it, ignoring export metadata such as the timestamp and file name, and a second import of the same archive SHALL change no stored value: no record's modification timestamp moves and the archive exported after the second import equals the one exported after the first (Decision 4; that a no-op import also leaves no pending changes is T-2334's, not this feature's).  ++## Verification++- `make test-core` covers the deletion contract over a small seeded fixture: every entity reaches zero rows, each chunk boundary validates clean, an interrupted run (a save refused through the repository's save boundary) leaves an openable library and a sidecar and a second run finishes, the spool and owed markers listed at the start are gone and a later-landing file survives, the diagnosis set is empty, and the export → empty → import round trip of [5.1](#5.1) is exact. A schema-coverage test fails when the live schema gains an entity the deletion does not handle.+- The deletion over the M4 performance fixture is reported in the M4 host suite, not budgeted; its regression ceiling is drawn from the first quiet-host measurement and recorded in the verification run.+- A UI journey on the `Development` simulator drives the row through confirm and cancel, and through the refusal path over the existing torn-group tolerated-state scenario.+- The share-sheet completion arm and the propagation of the deletions to a second device are verified by hand on `Development` installs, under the device-run rule in `CLAUDE.md`.
specs/empty-library/runbook.md Added +486 / -0
diff --git a/specs/empty-library/runbook.md b/specs/empty-library/runbook.mdnew file mode 100644index 00000000..e16fa001--- /dev/null+++ b/specs/empty-library/runbook.md@@ -0,0 +1,486 @@+# Device Runbook: Empty Library++T-2118. Requirements: [`requirements.md`](requirements.md) · Design:+[`design.md`](design.md) · Decisions: [`decision_log.md`](decision_log.md) ·+Owner steps: [`prerequisites.md`](prerequisites.md)++Nothing mirrors on the simulator or the host, and the share sheet's *completed*+arm cannot be driven from a UI test, so the arms below are the only verification+of [2.3](requirements.md#2.3)'s positive signal, [3.10](requirements.md#3.10) and+the design's last three Risks and Assumptions. The runbook is not an acceptance+criterion; it is how the field evidence is gathered and recorded.++## The rules that govern every step++**`Development` installs only.** The feature does not exist in `Personal`: the+row, its model and the deletion pass are all compiled out of that configuration+(Req [1.1](requirements.md#1.1), Q3, Q22). There is no `Personal` step in this+runbook and none may be added — a routine that deletes every row does not run+against the real library, under any approval.++**The steps are the owner's to run, not an agent's to work through.** This file+existing, and task 21 having written it, is not consent to run any of it.++**A `Development` install is not device-local.** Both configurations mirror to+CloudKit (`CLAUDE.md`, Sync), so every device signed into this iCloud account+with the `Development` build installed shares one dev library. The emptying+deletes through that mirror by design (Decision 1), so it empties the dev+library **on all of them**. That is the point of the second-device arm, and it is+why step 1 starts by putting an archive somewhere the app cannot reach.++**Every arm starts from a full library and ends with an empty one.** Because the+devices share one library, two arms cannot both start full unless the archive is+imported back in between. Each step below says what it starts from; step 1 is how+you get back there.++## Prerequisites++- **The phone**: `make install` (`Asterism Development`, bundle+  `me.nore.ig.Asterism.dev`). A `Development` install needs no approval at the+  moment of running under `CLAUDE.md`, but it does join the shared dev library+  through CloudKit.+- **The Mac**: `make build-mac`, then open the built product at+  `DerivedData/Build/Products/Development/Asterism.app`. There is deliberately no+  `install-mac` target; opening the **`Development`** product is the exempt case,+  and opening a `Personal` product is not.+- Both signed into the same iCloud account, both on the network, both opened+  once so each has a ready library and has seen the other's records.+- A dev library with something in it: entries, works and taught sites, at least+  one work carrying a cover (step 5 measures the external-storage blobs), and —+  for the dialog's second sentence to read non-trivially — at least one waiting+  and one set-aside capture in the preserved-capture area.+- Console.app on the Mac, for the log. Xcode is not needed: no step here uses the+  debugger.++## Reading the log++Console.app, with the phone selected for the phone arm and the Mac's own log for+the Mac arm, filtered:++```+subsystem:me.nore.ig.Asterism category:EmptyLibrary+```++and, for the hand-off itself, the sharing seam's own category beside it:++```+subsystem:me.nore.ig.Asterism category:DocumentExport+```++**Every line is `notice`**, as `category:BackgroundExport`'s are, except the+interruption, which is `error`. Notice lines are shown without Include Debug+Messages and are persisted, so a pass that ran before Console was streaming can+still be recovered afterwards with `log collect` — which is exactly why this+feature does not log at debug: the pass is the destructive one, and evidence of+what it deleted must survive not having watched it. Streaming is still the+easier way to read a run as it happens.++Both configurations log under the same subsystem literal, so tell builds apart by+**process** (the `Asterism` process from the `me.nore.ig.Asterism.dev` bundle),+never by the filter. The pass carries counts and reasons only — no reader content+(Req [4.3](requirements.md#4.3)) — so every field is public in both builds.++A completed pass over a non-empty library emits:++```+Emptying started: <n> waiting, <m> set aside, <k> owed markers+Sweep 1 phase 1 entries: <n> rows deleted+Sweep 1 phase 2 memberships: <n> rows deleted+Sweep 1 phase 3 characters: <n> rows deleted+Sweep 1 phase 4 work attachments: <n> rows deleted+Sweep 1 phase 5 works: <n> rows deleted+Sweep 1 phase 6 directories: <n> rows deleted+Sweep 1 phase 7 sites and rules: <n> rows deleted+Sweep 2 phase 1 entries: 0 rows deleted+… six more zeros …+Emptying deleted <total> rows; cleanup done+```++**Fourteen phase lines is the healthy shape**: the sweep loop stops after the+first sweep that deletes nothing (Q31), so a sweep 2 of all zeros and no sweep 3+is what "nothing arrived while the lock was held" looks like. A non-zero sweep 2,+and therefore a sweep 3, means the mirror delivered rows into an entity whose+phase had already passed — expected in the second-device arm, and the thing that+arm is watching (step 2's Observe).++The interruption line is the one at error level:++```+Emptying interrupted in <phase>: <NSErrorDomain> code <n>; <m> rows remain+```++The error is named by domain and code, never by message, because a save error's+description names the row it failed on and that row is reader content.++The app target logs the row's own phase transitions under the same category, all+at notice (Req [4.3](requirements.md#4.3)):++```+Empty library: exporting the backup+Empty library: the archive is on the sharing surface+Empty library: the sharing surface closed+Share completed delivered by the activity's report for settings-empty-library-share-sheet+Empty library: the share completed; emptying started      ← or …the share was cancelled; nothing is deleted+Empty library finished: <n> rows removed+```++plus `Empty library refused: the backup export was refused` at notice when the+export is refused, and `Empty library failed` at error for every terminal+failure.++**The `Share … delivered by …` line is the sharing seam's, not the row's**+(`documentExporter` in `PlatformModifiers.swift`, T-2118), and it is what says+*how* the outcome above it was arrived at:++```+Share completed delivered by the activity's report for settings-empty-library-share-sheet+Share cancelled delivered by the dismissal's grace period for settings-empty-library-share-sheet+```++The first is the ordinary hand-off: `UIActivityViewController` reported, whether+before the sheet closed or — Save to Files — up to three seconds after it. The+second is the dismissal's grace period running out with nothing reported, which+is what a swipe-away looks like. These lines are under `category:DocumentExport`,+not `EmptyLibrary`: every share on iOS logs one, so the sheet identifier at the+end is what tells the Empty Library sheet from the Export Backup row's+(`settings-backup-share-sheet`) and from a markdown export's. The Mac's save+panel logs neither; the ordering hazard is the iOS arm's.++One more line under that category is the one to look for if a genuine hand-off+is ever followed by nothing again:++```+Share report for presentation <n> dropped; presentation <m> is current for settings-empty-library-share-sheet+```++It means a report arrived carrying an older presentation's number, or for a+presentation already answered, and was refused. After a swipe-away on a second+run it is the guard working (Q43's hazard). After a completed Save to Files with+no `Share completed …` line beside it, it is the numbering failing, and a Fail.++Two `EmptyLibrary` lines that belong to no step, for when something refuses:+`Emptying did not start: <domain> code <n>; nothing was deleted` is a run that+could not take the library lock, which leaves no interrupted notice behind; and+an interruption whose reason reads `no progress in <Entity>` is a chunk that+saved without removing its rows.++**Two things that look like failures and are not.** Stay on Settings until the+row reaches its finished sentence: the row's model lives with the screen, so if+you leave while it is waiting or emptying, the pass still runs and still logs,+but the sentence lands on a model nobody renders and the row you come back to is+idle. And use **Save to Files** for the completed arm: an activity that takes you+out of the app (Mail, Messages, AirDrop to another device) suspends it, the+three-second grace keeps counting on a continuous clock, and the hand-off may be+reported as cancelled when you return. Nothing is deleted in that case and a+retry works; it is a limit of Decision 6, not a regression.++## What the row says++Every sentence below comes from `EmptyLibraryModel`; the row shows no wording of+its own. Settings → the collapsed **Debug** disclosure at the end of the screen →+**Empty Library…** (the last row, and the only destructive one).++| Phase | What is on screen |+|---|---|+| Idle | `Empty Library…`, trash glyph, destructive role |+| Dialog | Title `Empty the library?`; message `<n> entries, <m> works and <k> sites will be deleted. <a> waiting and <b> set-aside captures are discarded with them. Every device signed into this iCloud account is emptied when it next syncs. A backup is exported first, and nothing is deleted until you have saved it.` — with `No captures are waiting to be discarded.` in place of the second sentence when both counts are zero. Buttons `Empty Library` (destructive) and `Cancel` |+| Exporting | spinner + `Exporting a backup…` |+| Sharing | spinner + `Waiting for the backup to be saved…` |+| Sharing surface closed | the same spinner and sentence, for up to three seconds while the activity's report is waited for (T-2118) |+| Emptying | spinner + `Emptying the library…` |+| Finished | `Removed <n> rows. Other devices empty when they next sync.` and an `Empty Library…` button |+| Finished, cleanup failed | `Removed <n> rows, but some pending captures or markers could not be removed: <reason>.` |+| Interrupted | `Removed <n> rows; phase "<phase>" failed: <reason>. <m> rows remain. Run Empty Library again to remove them.` with `Try Again` |+| Export refused | the Export Backup row's own refusal sentence, `Try Again`, and `Check Library` where the refusal routes there |+| Next open after an interruption | a second notice row in the **Backup** section: `Emptying the library did not finish. Some records remain. Run Empty Library again to remove them.` |++The counts are physical rows (Q16) and informative: the deletion removes whatever+the library holds when it runs, not what the dialog counted.++---++## Step 1 — The archive you keep, and the state every arm starts from++The emptying exports its own backup and will not delete until you have handed it+off, but that archive goes out through a share sheet in the middle of a+destructive action. Take a separate one first, deliberately, and put it where the+app cannot reach it.++**Action.**++1. On the phone, Settings → **Backup** → **Export Backup**, and save the archive+   to a location outside the app — Files → On My Mac, AirDrop to the Mac, or+   iCloud Drive. Copy it to the Mac and keep it until every step here has passed.+2. Confirm it imports: on the Mac, Settings → Backup → import that archive, and+   check the counts it reports against the library you can see.++**Pass.** You hold one archive, off the device, that the app has accepted on+import. This is the only way back from every step below.++**Between arms.** After an emptying, get back to a full library by importing that+archive again on one device and letting the others sync. Import is upsert by UUID+and never deletes (`cloudkit-mirroring` Decision 2), so importing it twice is+harmless.++---++## Step 2 — The phone arm: a completed hand-off, with the second device online++The arm nothing else covers. `UIActivityViewController` reports whether an+activity ran, and the deletion begins on that report and on nothing else+(Reqs [2.3](requirements.md#2.3), [2.4](requirements.md#2.4), Q10).++**Setup.** A full library. The Mac's `Development` app **open and online for the+whole run** — that is the second-device half of this step and the design's third+assumption (three sweeps are enough to drain rows the mirror delivers during the+pass). Console streaming, phone selected. Note the row counts+the phone shows on Recent and on the works list before you start.++**Action.**++1. Settings → Debug → **Empty Library…**.+2. Read the dialog against the table above: the three row counts, the capture+   counts, the every-device sentence, the backup sentence. Tap **Empty Library**.+3. The row reads `Exporting a backup…`, then the share sheet appears with the+   staged archive, and the row reads `Waiting for the backup to be saved…`.+4. **Complete an activity** — Save to Files, or AirDrop to the Mac. Do not swipe+   the sheet away; that is the cancel arm, and the UI suite already covers it.++**Observe.**++- The row goes to `Emptying the library…` with no second prompt (Req 2.4), and+  does not accept a second tap while it is there (Req 4.1). With **Save to+  Files** it holds `Waiting for the backup to be saved…` for a moment first:+  the activity reports after the sheet has closed, and the row waits for it+  (T-2118). Console names the path — `Share completed delivered by the+  activity's report …` — immediately before `the share completed; emptying+  started`. A `Share cancelled delivered by the dismissal's grace period …`+  after a completed activity is this defect back, and is a Fail.+- Console shows the started line, the phase lines, and+  `Emptying deleted <n> rows; cleanup done`.+- The row finishes `Removed <n> rows. Other devices empty when they next sync.`+- Every screen on the phone is empty: Recent, Today, the works list, Sites. The+  work-type and creator-role pickers still work, over an empty list, until the+  next app open re-mints the defaults (Req [3.9](requirements.md#3.9), Q6, Q17).+- The **Captures** section reports nothing preserved, and the archive you+  completed the share with is where you sent it.++**Pass.** `cleanup done`, a finished sentence whose count is in the same order as+the dialog's three counts plus everything else the library held, and no diagnosis+anywhere — Settings → Debug → Check Library is clean (Req+[3.8](requirements.md#3.8)).++**Watch: the sweeps.** With the Mac online this is where a non-zero sweep 2 is+plausible. Record which sweeps were non-zero and what they deleted. A sweep 3+that still leaves rows reports `Emptying interrupted in sweep: 3 sweeps left <n>+rows behind`, which is Q31 working as designed — the remedy is step 3's second+run, not a code change — but **record the counts**: if three sweeps routinely do+not converge, the assumption behind Q31 is wrong.++**Watch: the stale publish.** The actor is blocked for the pass's duration+(Q23), so a tab refresh queued before it can surface afterwards holding rows+fetched before it — the design's third Risk. Move between Recent and Today during+and after the pass and record anything that shows rows the library no longer+holds. `refreshDiagnosesAndSnapshots()` runs after the pass, so a screen that is+briefly stale and then correct is not the defect; one that stays stale is.++**Fail.** Nothing deleted after a completed activity means the outcome did not+reach the model — the iOS half of `DocumentExportOutcome`, and the arm this step+exists for. Deletion *without* completing an activity is the worse direction and+breaches Req 2.3 outright. A `cleanup failed` sentence names which listing+survived; check the preserved-capture area and `ExportOwed/` before re-running.++---++## Step 3 — The second device, and the second run that clears the residue++Completion is local (Q20, Req 3.10): rows deleted here and the deletions in the+mirror's history. What lands on the other device, and what the other device puts+back, is Q26.++**Setup.** Straight after step 2, without re-seeding. The Mac still open.++**Action.**++1. Watch the Mac. Its library empties as the tombstones arrive; give it minutes,+   not seconds, and bring it to the foreground to prompt the arrival debounce.+2. Once it has settled, open the app on **both** devices at least once. The+   defaults are re-minted at every open under their fixed UUIDs, by design.+3. On the phone, run **Empty Library…** again — dialog, confirm, hand the archive+   off as before.++**Observe.**++- On the Mac after step 2's pass: every screen empty, and Check Library clean.+- The second run's dialog reads zero entries, works and sites, because those are+  the only rows it counts (Q16) and the residue is neither. What says the residue+  was there is the **finished sentence**: `Removed <n> rows.` with `n` in the low+  tens — the re-minted work types and creator roles, and any membership row the+  Mac's arrival reconcile minted for an arriving Entry whose Work had already+  gone, which has no tombstone of its own because it was minted after the+  deletion (Q26). A second run that reports `Removed 0 rows.` means nothing came+  back.+- The second run finishes `Removed <n> rows.` with `cleanup done`.+- After the next app open on each device the directory tables are back again.+  **That is not a failure**: Req 3.9 and Q6 say the action does not re-seed them+  and the next open does.++**Pass.** The Mac is empty without anything being done to it, and the second run+removes what arrived after the first. A residue in the low tens is expected; a+residue that includes entries, works or sites is not — record what kind of rows+came back and how many, because that is a different fault from Q26's.++**Fail.** The Mac still holding entries or works hours later, with both devices+online, means the deletions did not mirror — which would put Req+[3.2](requirements.md#3.2) in question, not this runbook. Record it and stop.++---++## Step 4 — The Mac arm: the save panel's completed result++The macOS half of Req 2.3. The Mac's sharing surface is `fileExporter`'s save+panel, not a share sheet, and a **failed** save counts as a cancellation because+nothing left the app either way.++**Setup.** Import the archive from step 1 so the library is full again, on the+Mac. The phone may be on or off; it is not what this step is about.++**Action.** On the Mac: Settings → Debug → **Empty Library…** → confirm → the+save panel appears → **choose a location and save**.++**Observe.** The row goes `Exporting a backup…` → `Waiting for the backup to be+saved…` → `Emptying the library…` → `Removed <n> rows. Other devices empty when+they next sync.`, and the Mac's own log carries the pass's lines and the row's, but no `Share … delivered by …` line: that one is the iOS arm's.++**Also run the cancel half here**, because it is the one the UI suite cannot+reach on this platform: re-seed, start again, and press **Cancel** in the save+panel. The staged archive is removed and the row returns to idle with nothing+deleted (Req 2.3). A save that fails — pick a location you cannot write to —+must behave the same way.++**Pass.** Saving deletes; cancelling and failing do not.++**Fail.** Either direction wrong is the macOS half of `DocumentExportOutcome`+(`init(fileExporterResult:)` and `onCancellation`), which no host test can+observe.++---++## Step 5 — The bytes on disk, before and after++The design assumes SwiftData removes an `.externalStorage` blob's file when the+owning row's deletion is saved. Work covers are the only external-storage column,+so this is measured over a library that carries some. Reclaiming the bytes is a+Non-Goal — the question is only whether the blob files go.++**Where.** On the Mac, the dev App Group container:++```+~/Library/Group Containers/group.me.nore.ig.Asterism.dev/Library/Application\ Support/+```++`AsterismV3.sqlite` and its `-wal`/`-shm` companions live there, with the+external-storage blobs in the `…_SUPPORT/_EXTERNAL_DATA` directory beside them+(a dot-prefixed name, so `ls -la`).++**Action.**++1. With a full library carrying covers, and the app **quit** so the WAL is+   checkpointed, record `du -sh` for that directory, `du -sh` for the+   `_EXTERNAL_DATA` directory, and `ls -1 | wc -l` for the blob files.+2. Run one emptying (step 4's Mac arm serves; do not run a separate one for this).+3. Quit the app again and take the same three readings.++The phone's container can be read the same way if you want the number from iOS,+through the container copy `specs/background-export/runbook.md` documents, with+`--domain-identifier group.me.nore.ig.Asterism.dev`.++**Expect.** The blob count goes to zero and `_EXTERNAL_DATA` collapses to+nothing. The `.sqlite` file itself does **not** shrink, and must not be read as a+failure: freeing pages inside the file is SwiftData's, and Non-Goals put byte+reclamation outside this feature.++**Fail.** Blob files that survive their rows are the assumption being wrong.+Record the counts and the sizes here; the remedy is a note in the verification+run, not a change to the pass.++---++## Step 6 — A stale completion must not reach a later share sheet++Raised in review. The deletion begins on a completion signal, and a `.completed`+left standing anywhere would make a **later**, unrelated dismissal delete the+library without anyone handing anything off.++**Setup.** Straight after a **completed hand-off and a finished emptying** — step+2's run, the row showing `Removed <n> rows.` — on the phone. Then **re-import the+step 1 archive first**, so the library has something to lose: a second run over+an already-empty library would report zero rows whether the guard holds or not,+which proves nothing.++**Action.**++1. Settings → **Backup** → **Export Backup**. When the share sheet appears,+   **swipe it away** without completing an activity.+2. Then Settings → Debug → **Empty Library…** → confirm → and **swipe that share+   sheet away** too, again without completing anything. This is the run that+   matters: the completion delivered during the finished run must not reach this+   dismissal.++**Observe.** Nothing is deleted by either dismissal. The library still holds+everything the re-import brought back, and the Empty Library row returns to idle+— after up to three seconds on `Waiting for the backup to be saved…`, which is+the grace period the dismissal waits out for a report that never comes (T-2118),+not a run starting. Console, filtered on the category, shows for action 2 above:++```+Empty library: exporting the backup+Empty library: the archive is on the sharing surface+Empty library: the sharing surface closed+Share cancelled delivered by the dismissal's grace period for settings-empty-library-share-sheet+Empty library: the share was cancelled; nothing is deleted+```++and **no** `Empty library: the share completed; emptying started` and no+`Emptying started` line from the pass. A `Share completed …` line for **this**+sheet would be the stale completion arriving, and is the failure below.++**Pass.** No emptying begins from a dismissal, however recently a completion was+delivered to either surface, and the library is intact afterwards.++**Fail.** Any deletion here is the defect this step exists to catch: every+presentation of the sharing surface takes a number, every report carries the+number of the presentation it came from, a report for an older presentation is+dropped, one delivery is all a presentation gets, and the model accepts an+outcome only while it is waiting on its own share — so a failure means one of+those guards is gone. Stop and report it before running anything else.++---++## Results++Fill in as the steps are run. Record row counts, Console lines and timings in+Notes for anything that did not pass first time. The M4 host band belongs in+`verification-run.md`, not here.++| # | Step | Date | Device / configuration | Outcome | Notes |+|---|------|------|------------------------|---------|-------|+| 1 | The archive you keep | | | | |+| 2 | Phone arm: completed hand-off, second device online | | | | |+| 3 | Second device after syncing, and the second run | | | | |+| 4 | Mac arm: save panel completed, cancelled, failed | | | | |+| 5 | Bytes on disk, before and after | | | | |+| 6 | Stale completion does not reach a later share sheet | | | | |++Measurements for step 5:++| Reading | Application Support | `_EXTERNAL_DATA` | Blob files |+|---|---|---|---|+| Before | | | |+| After | | | |++Sweep counts for step 2, one row per run:++| Date | Sweep 1 rows | Sweep 2 rows | Sweep 3 rows | Outcome |+|---|---|---|---|---|+| | | | | |
specs/empty-library/tasks.md Added +199 / -0
diff --git a/specs/empty-library/tasks.md b/specs/empty-library/tasks.mdnew file mode 100644index 00000000..0ff1c195--- /dev/null+++ b/specs/empty-library/tasks.md@@ -0,0 +1,199 @@+---+references:+    - specs/empty-library/requirements.md+    - specs/empty-library/design.md+    - specs/empty-library/decision_log.md+---+# Empty Library++## Core pass++- [x] 1. Write EmptyLibraryTests for the phases, the sweeps and schema coverage <!-- id:7ng6a6k -->+  - New suite Packages/AsterismCore/Tests/AsterismCoreTests/EmptyLibraryTests.swift over a small seeded store: the tolerated-state fixture harness with the composed and characters seeds+  - Zero rows for each of the 17 entities afterwards, directories included; a second run reports completed with rowsDeleted 0+  - A validating RepositorySaveStrategy runs LibraryValidator.validate(context:) after every save and records any tuple diagnosis absent before the chunk; tolerated states are not counted (Q11)+  - A strategy that inserts an Entry on phase 8's save: the second sweep deletes it and the report is completed (Q31)+  - ModelContractTests: the phase list's entity names as a set equal AsterismSchemaV15.models' names, on schemaEntityLists' shape+  - A store seeded with a tolerated-state diagnosis ends with empty diagnostics and an empty quarantine map+  - Stream: 1+  - Requirements: [3.1](requirements.md#3.1), [3.2](requirements.md#3.2), [3.3](requirements.md#3.3), [3.9](requirements.md#3.9)+  - References: specs/empty-library/design.md++- [x] 2. Implement the deletion pass in LibraryRepository+EmptyLibrary <!-- id:7ng6a6l -->+  - Whole file inside #if DEBUG || ASTERISM_PERFORMANCE_TESTING (Q22); types EmptyLibraryInventory, EmptyLibraryReport; emptyLibraryInventory() under a shared lock+  - Eight phases in the design's order through one generic chunk loop: fetchLimit bulkOperationBatchSize, no sort, context.delete per row, saveStrategy.save per chunk; never ModelContext.delete(model:where:)+  - Phase 1 detaches the chunk's entries from Site.entries with one removeAll per Site, matched by ObjectIdentifier; add the second sanctioned entry to SiteInverseReachTests. No other inverse is detached (Q32)+  - One withLockedContext exclusive around up to three sweeps; bulkOperationInProgress with defer, refireDeferredReconcile after the closure returns, exactly as confirmImport+  - After the sweeps: validateStore, diagnostics and setQuarantine from its result; refuse unless capabilities.gate is multiSite+  - Logger category EmptyLibrary: started with inventory, one line per phase per sweep, deleted, counts and reasons only+  - Blocked-by: 7ng6a6k (Write EmptyLibraryTests for the phases, the sweeps and schema coverage)+  - Stream: 1+  - Requirements: [3.1](requirements.md#3.1), [3.2](requirements.md#3.2), [3.3](requirements.md#3.3), [3.5](requirements.md#3.5), [3.8](requirements.md#3.8), [3.10](requirements.md#3.10), [4.3](requirements.md#4.3)+  - References: Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+ConfirmImport.swift, Packages/AsterismCore/Sources/AsterismCore/DuplicateReconciler.swift++- [x] 3. Write tests for interruption, the sidecar and the deferred re-fire <!-- id:7ng6a6m -->+  - A save strategy that throws on the Nth save: report is interrupted with rowsDeleted equal to the chunks already saved and remaining above zero; the store reopens; interruptedEmpty() is non-nil; a second run reaches zero rows and clears the sidecar+  - An unwritable sidecar location refuses the run before anything is deleted (Q30)+  - On reconcileDefersDuringImportAndReFires' shape with setBulkOperationInProgressForTesting: after a completing run and after an interrupted run, reconcileDeferred is false, isBulkOperationInProgress() is false and the deferred pass ran+  - After any rollback the test re-faults before asserting; SwiftData does not restore @Model properties+  - Blocked-by: 7ng6a6l (Implement the deletion pass in LibraryRepository+EmptyLibrary)+  - Stream: 1+  - Requirements: [3.4](requirements.md#3.4), [3.5](requirements.md#3.5), [4.2](requirements.md#4.2)+  - References: Packages/AsterismCore/Tests/AsterismCoreTests/BackupImportTransactionTests.swift++- [x] 4. Implement the sidecar, the refusal to start without it and the interrupted report <!-- id:7ng6a6n -->+  - LibraryConfiguration.emptySidecarURL = rootDirectory/AsterismEmpty.inProgress; InterruptedEmptyReport with startedAt; interruptedEmpty() reads it, and a corrupt file still reports, as readImportSidecar does+  - Written before the first save and after the listings; a write failure throws. Cleared once the sweeps end with zero rows, whatever the cleanup verdict+  - A chunk whose save throws is rolled back and the pass returns interrupted(phase, reason) with rowsDeleted and remaining; nothing after the first save throws (Q29)+  - A third sweep that still finds rows returns interrupted with the remaining count; cleanup is skipped while rows remain+  - Blocked-by: 7ng6a6m (Write tests for interruption, the sidecar and the deferred re-fire)+  - Stream: 1+  - Requirements: [3.4](requirements.md#3.4), [3.5](requirements.md#3.5), [4.2](requirements.md#4.2)++- [x] 5. Write tests for the spool inventory, its discard and the owed-marker cleanup <!-- id:7ng6a6o -->+  - Seed waiting, set-aside and refused records, a report.json and owed markers; everything listed at the start is gone afterwards+  - A save strategy that on the first save writes one record file into pending/ and one marker into ExportOwed/ by path, as the extension's atomic rename does: both survive (Q13)+  - incoming/ names listed at the start are unlinked; a name that appears later is untouched (Q24)+  - A spool directory made unremovable gives cleanup failed with the rows still zero and the sidecar cleared; the verdict comes from re-listing because ExportOwedMarker.clear reports nothing+  - Blocked-by: 7ng6a6n (Implement the sidecar, the refusal to start without it and the interrupted report)+  - Stream: 1+  - Requirements: [3.6](requirements.md#3.6), [3.7](requirements.md#3.7), [4.2](requirements.md#4.2)++- [x] 6. Implement the spool inventory and discard and the cleanup verdict <!-- id:7ng6a6p -->+  - PendingCaptureInventory, inventory() and discard(_:) as an extension in the pass's file, under the same gate; discard composes delete(id:) for pending, deleteQuarantined(id:) for set aside, deleteRefusal(id:), the report path and incoming names, and returns what is still present+  - The pass constructs PendingCaptureSpool and ExportOwedMarker from its own configuration (Q27); both listings happen before the sidecar and the lock, removal after the sweeps+  - emptyLibraryInventory() gains the waiting and set-aside counts from the same spool+  - Blocked-by: 7ng6a6o (Write tests for the spool inventory, its discard and the owed-marker cleanup)+  - Stream: 1+  - Requirements: [3.6](requirements.md#3.6), [3.7](requirements.md#3.7), [4.2](requirements.md#4.2)+  - References: Packages/AsterismCore/Sources/AsterismCore/PendingCaptureSpool.swift, Packages/AsterismCore/Sources/AsterismCore/ExportOwedMarker.swift++- [x] 7. Write the export, empty and import round-trip test and the empty-directory tolerance tests <!-- id:7ng6a6q -->+  - Export, empty, import, export over the seeded store: the two payloads are equal with exportedAt and the file name masked (Q15)+  - A second import of the same archive records hasChanges false at every save through a counting save strategy and moves no modifiedAt+  - A record captured after the export and before the emptying is absent from the archive and gone afterwards (Req 2.5)+  - With zero work types and roles: a capture commits, and the work editor model drafts other and an empty role list+  - These exercise existing import and editor code; a failure here is a bug to fix in that code, not a test to loosen+  - Blocked-by: 7ng6a6p (Implement the spool inventory and discard and the cleanup verdict)+  - Stream: 1+  - Requirements: [2.5](requirements.md#2.5), [3.9](requirements.md#3.9), [5.1](requirements.md#5.1)++- [x] 8. Add the M4 empty-library performance arm with a provisional ceiling <!-- id:7ng6a6r -->+  - New suite M4EmptyLibraryPerformanceTests, one arm empty-library-m4: three samples, each over a freshly seeded covered M4 fixture through the M4DuplicatePerformanceStore harness shape+  - reportPerformance plus expectWithinCeiling outside any known-issue block, provisional ceiling 60 s (Q18)+  - The per-phase log lines are the evidence for the design's first risk: whether an undetached inverse dominates+  - Blocked-by: 7ng6a6l (Implement the deletion pass in LibraryRepository+EmptyLibrary)+  - Stream: 1+  - Requirements: [3.2](requirements.md#3.2)+  - References: Packages/AsterismCore/Tests/AsterismCoreTests/M4DuplicateScalePerformanceTests.swift++## App++- [x] 9. Write tests for DocumentExportOutcome <!-- id:7ng6a6s -->+  - AsterismTests: init(activityCompleted:) maps true to completed and false to cancelled; init(fileExporterResult:) maps success to completed and failure to cancelled+  - Stream: 2+  - Requirements: [2.3](requirements.md#2.3)++- [x] 10. Implement the share completion signal in documentExporter and ShareSheet <!-- id:7ng6a6t -->+  - documentExporter becomes a ViewModifier holding the outcome in @State, default cancelled; onCompletion takes a DocumentExportOutcome+  - ShareSheet gains onOutcome, installed as completionWithItemsHandler in makeUIViewController and writing through a binding into the modifier's state; the sheet's onDismiss delivers the stored outcome and resets it+  - macOS: the fileExporter result maps through the initialiser; onCancellation delivers cancelled+  - Existing callers, the Settings backup row and MarkdownExportShare, ignore the argument and keep their binding set as the dismissal+  - No new #if os: both files are existing platform seams; PlatformSeamTests must stay green+  - Blocked-by: 7ng6a6s (Write tests for DocumentExportOutcome)+  - Stream: 2+  - Requirements: [2.3](requirements.md#2.3), [2.4](requirements.md#2.4)+  - References: Asterism/Asterism/Support/PlatformModifiers.swift, Asterism/Asterism/Views/ShareSheet.swift, Asterism/Asterism/Views/MarkdownExportShare.swift++- [x] 11. Write tests for BackupExportStage <!-- id:7ng6a6u -->+  - export() returns the staged URL or an ExportRefusal carrying the message and routesToCheckLibrary; torn groups route, every other refusal does not+  - cleanup() removes the staged file and is idempotent; messages never carry titles, notes or URLs except the cover refusal's work title, as today+  - Stream: 2+  - Requirements: [2.1](requirements.md#2.1), [2.2](requirements.md#2.2)++- [x] 12. Extract BackupExportStage from SettingsBackupModel <!-- id:7ng6a6v -->+  - Move export, the staged URL, cleanup, privacySafeMessage, exportMessage, tornGroupsMessage and diagnosticCategory into BackupExportStage in SettingsBackupModel.swift; SettingsBackupModel keeps its four states and drives the stage (Q33)+  - SettingsBackupModelTests stay green unchanged: this is a refactor with no behaviour change on the backup row+  - Blocked-by: 7ng6a6u (Write tests for BackupExportStage)+  - Stream: 2+  - Requirements: [2.1](requirements.md#2.1), [2.2](requirements.md#2.2)+  - References: Asterism/Asterism/ViewModels/SettingsBackupModel.swift++- [x] 13. Write EmptyLibraryModelTests <!-- id:7ng6a6w -->+  - Transitions: cancel from confirming, a refusal reaching failed with the route on torn, share cancelled returning to idle with the staged file removed+  - shareDismissed then handleShareOutcome, and the reverse order, both reach emptying exactly once and never re-enter sharing (Q34)+  - The staged file is kept until the pass reaches finished or failed (Q28)+  - An interrupted report reaches failed with the rows removed, the phase and the remaining count; a cleanup failure reaches finished with its sentence; the completed sentence says other devices empty when they next sync+  - prepareConfirmation is accepted from idle, finished and failed; a second tap while exporting, sharing or emptying does nothing+  - Blocked-by: 7ng6a6l (Implement the deletion pass in LibraryRepository+EmptyLibrary), 7ng6a6t (Implement the share completion signal in documentExporter and ShareSheet), 7ng6a6v (Extract BackupExportStage from SettingsBackupModel)+  - Stream: 2+  - Requirements: [1.3](requirements.md#1.3), [2.2](requirements.md#2.2), [2.3](requirements.md#2.3), [2.4](requirements.md#2.4), [3.10](requirements.md#3.10), [4.1](requirements.md#4.1), [4.2](requirements.md#4.2)++- [x] 14. Implement EmptyLibraryModel and EmptyLibraryDependencies <!-- id:7ng6a6x -->+  - Asterism/Asterism/ViewModels/EmptyLibraryModel.swift, whole file #if DEBUG; EmptyLibraryDependencies is an unconditional app-target struct of stage, inventory and empty+  - shareDismissed and handleShareOutcome are synchronous and both leave sharing before any await; the pass runs in a Task started from handleShareOutcome+  - Every sentence the row shows comes from the model, as SettingsView's convention requires+  - Blocked-by: 7ng6a6w (Write EmptyLibraryModelTests)+  - Stream: 2+  - Requirements: [1.3](requirements.md#1.3), [2.2](requirements.md#2.2), [2.3](requirements.md#2.3), [2.4](requirements.md#2.4), [3.10](requirements.md#3.10), [4.1](requirements.md#4.1), [4.2](requirements.md#4.2)++- [x] 15. Write AppLibraryModel tests for the emptying entry point, the drain fence and the interrupted notice <!-- id:7ng6a6y -->+  - emptyLibrary() awaits a drain in flight before calling the repository, and a drain requested while it runs returns without a report and without touching the watcher bracket (Q35)+  - After any outcome: refreshDiagnosesAndSnapshots, dismissDrainReport and refreshPendingCaptureSurfaces have run, so Settings shows no waiting count, set-aside rows or drain report+  - interruptedEmptyNotice is non-nil when the sidecar exists at bootstrap and nil after a completed run+  - Blocked-by: 7ng6a6n (Implement the sidecar, the refusal to start without it and the interrupted report), 7ng6a6p (Implement the spool inventory and discard and the cleanup verdict), 7ng6a6x (Implement EmptyLibraryModel and EmptyLibraryDependencies)+  - Stream: 2+  - Requirements: [3.4](requirements.md#3.4), [3.5](requirements.md#3.5), [3.7](requirements.md#3.7), [3.8](requirements.md#3.8)++- [x] 16. Implement the AppLibraryModel entry point, the drain fence and the interrupted notice <!-- id:7ng6a6z -->+  - All under #if DEBUG in AppLibraryModel: emptyLibraryModel() building the stage from the same exporter construction as settingsBackupModel(), emptyLibrary(), emptyLibraryInFlight, interruptedEmptyNotice read beside interruptedImport at bootstrap+  - drainPendingCaptures returns before passDidStart while the flag is set; the next foreground activation retries as today+  - Blocked-by: 7ng6a6y (Write AppLibraryModel tests for the emptying entry point, the drain fence and the interrupted notice)+  - Stream: 2+  - Requirements: [3.4](requirements.md#3.4), [3.5](requirements.md#3.5), [3.7](requirements.md#3.7), [3.8](requirements.md#3.8)+  - References: Asterism/Asterism/ViewModels/AppLibraryModel.swift++- [x] 17. Write EmptyLibrarySettingsUITests <!-- id:7ng6a70 -->+  - New Asterism/AsterismUITests/EmptyLibrarySettingsUITests.swift, expandSettingsDebug with witness settings-empty-library-run+  - seeded-characters: tap, the dialog shows the counts, cancel, the row is idle and Recent is unchanged+  - seeded-characters: tap, confirm, settings-empty-library-share-sheet appears, dismiss, the row is idle and Recent is unchanged+  - seeded-tolerated-tornEntryGroup: tap, confirm, settings-empty-library-result names the refusal and settings-empty-library-check-library is present+  - The completed-share arm cannot be driven from a UI test and is the runbook's+  - Blocked-by: 7ng6a6z (Implement the AppLibraryModel entry point, the drain fence and the interrupted notice)+  - Stream: 2+  - Requirements: [1.1](requirements.md#1.1), [1.2](requirements.md#1.2), [1.3](requirements.md#1.3), [2.2](requirements.md#2.2), [2.3](requirements.md#2.3)+  - References: Asterism/AsterismUITests/BackgroundExportSettingsUITests.swift, Asterism/AsterismUITests/UIJourneySupport.swift++- [x] 18. Implement the Settings row, the dialog, the share presentation and the interrupted notice row <!-- id:7ng6a71 -->+  - SettingsView: emptyLibraryRow after thumbnailProbeRow in the Debug disclosure, #if DEBUG, on debugActionRow's state switch plus backupRow's failure arm; identifiers from the design's table, on the Text not the container+  - The confirmation dialog and the documentExporter are attached to the List, the dialog with presenting:, the exporter's binding set calling shareDismissed and its onCompletion calling handleShareOutcome+  - A second notice row on interruptedImportRow's shape, identifier settings-interrupted-empty-notice+  - SettingsView.init takes emptyLibrary: EmptyLibraryDependencies? unconditionally; SettingsScreen passes it on every build, as runBackgroundExport is passed+  - No view or model holds a ModelContext+  - Blocked-by: 7ng6a70 (Write EmptyLibrarySettingsUITests)+  - Stream: 2+  - Requirements: [1.1](requirements.md#1.1), [1.2](requirements.md#1.2), [1.3](requirements.md#1.3), [2.1](requirements.md#2.1), [3.4](requirements.md#3.4), [4.1](requirements.md#4.1), [4.2](requirements.md#4.2)+  - References: Asterism/Asterism/Views/SettingsView.swift, Asterism/Asterism/Layout/SettingsScreen.swift++## Verification++- [x] 19. Run the host and simulator suites and both Mac builds and fix what fails <!-- id:7ng6a72 -->+  - make test-core expects zero known issues and no new compiler warnings; make test-quick includes build-mac; make test-ui for the new journeys; make build-mac-release proves Personal compiles without the gated files+  - All simulator or host targets; nothing here touches a device+  - Blocked-by: 7ng6a6q (Write the export, empty and import round-trip test and the empty-directory tolerance tests), 7ng6a6r (Add the M4 empty-library performance arm with a provisional ceiling), 7ng6a71 (Implement the Settings row, the dialog, the share presentation and the interrupted notice row)+  - Stream: 1+  - Requirements: [1.1](requirements.md#1.1)++- [x] 20. Run the M4 empty-library arm on a quiet host, record the band and replace the provisional ceiling <!-- id:7ng6a73 -->+  - Host only: the new suite through make test-performance-m4 on a quiet host, measured in release with ASTERISM_PERFORMANCE_TESTING+  - Record the band and the per-phase split in specs/empty-library/verification-run.md, never in tasks.md; replace the 60 s ceiling with the measured band's ceiling+  - If one undetached inverse dominates, detach it the same way with its sanctioned entry, record the decision, and re-measure+  - One run is not a baseline: the suites are not reproducible, so record at least two runs+  - Blocked-by: 7ng6a72 (Run the host and simulator suites and both Mac builds and fix what fails)+  - Stream: 1++- [x] 21. Write runbook.md for the manual arms and document the row in CLAUDE.md <!-- id:7ng6a74 -->+  - specs/empty-library/runbook.md: the completed-share arm on the phone and the Mac, a second device online during the run and its state after syncing, the second run that clears the residue (Q26), the store size on disk before and after; Development installs only+  - CLAUDE.md: the row beside the Run background export paragraph, stating that Personal has neither the row nor the pass, and adding the one sanctioned Site deletion to the Never deleted list in Convergence+  - Blocked-by: 7ng6a71 (Implement the Settings row, the dialog, the share presentation and the interrupted notice row)+  - Stream: 1
specs/empty-library/verification-run.md Added +546 / -0
diff --git a/specs/empty-library/verification-run.md b/specs/empty-library/verification-run.mdnew file mode 100644index 00000000..51c5aba4--- /dev/null+++ b/specs/empty-library/verification-run.md@@ -0,0 +1,546 @@+# Verification Run: Empty Library++The evidence for tasks 19 and 20, recorded here rather than in `tasks.md`, which+`rune` owns.++**Date**: 2026-09-19+**Commit**: `2a18519a` (`T-2118/empty-library`), worktree+`.claude/worktrees/T-2118+empty-library`, with task 20's ceiling change in the+working tree.+**Host**: the project machine — Apple silicon, 10 cores, macOS 26.+**Host and simulator only.** No device target was run: nothing `Personal` was+installed or launched, no `xcrun devicectl` was invoked, and the Mac app was+never opened (`CLAUDE.md`).++**Two things about this host shaped the whole session, and both are recorded+rather than worked around.**++- **The console was locked for all of it.** `ioreg -n Root -d1 -a | grep -A1+  IOConsoleLocked` read `true` at the start, before each Mac build and at the+  end. Under the lock `make build-mac` and `make build-mac-release` fail at+  `CodeSign … errSecInternalComponent` *before* the app target compiles, and+  three pending-capture suites in `make test-core` fail EPERM on their own spool+  files. Neither is this branch's.+- **Another agent held the machine.** A `make test-ui` from the+  `.claude/worktrees/share-sheet-character-chips` worktree was running for most+  of the session and restarted itself when it finished (02:50 and 03:13 runs,+  both parented to `launchd`). Time Machine's `backupd` and `mediaanalysisd`+  were also active. The one-minute load average was **129 at the start of the+  session and 5.3 at the end**. Nothing here was killed to make room; the+  measurement below is read against that, and the reading turns out not to+  depend on it.++---++# Task 20: the M4 empty-library arm++## The command++The `test-performance-m4` target has no per-arm filter variable (`CORE_TEST`+belongs to `test-core`, which is debug and does not set+`ASTERISM_RUN_PHYSICAL_PERFORMANCE`), so the arm is run the way+`specs/catch-up-mode/verification-run.md` ran its own — the target's environment+and flags, narrowed to the one suite:++```+ASTERISM_RUN_PHYSICAL_PERFORMANCE=1 swift test \+  --package-path Packages/AsterismCore --no-parallel -c release \+  -Xswiftc -DASTERISM_PERFORMANCE_TESTING \+  --filter M4EmptyLibraryPerformanceTests+```++`--no-parallel` is load-bearing (`docs/agent-notes/testing.md`). The suite is+in the target's own filter since `f718dd11`, so the whole-target run in §4 below+picks it up without further change.++## The runs++Four runs of three samples each. Every sample seeds its own covered M4 fixture+and empties it; every one of the twelve passes deleted **7,009 rows** and+reported `completed` with `cleanup: done`.++| Run | median | p95 (= max, n=3) | min | spread | 1-min load at start | Exit |+|---|---|---|---|---|---|---|+| 1 | **5.544201 s** | 5.688929 s | 5.540727 s | 1.03× | 79.5 | 0 |+| 2 | **5.751699 s** | 5.767367 s | 5.687255 s | 1.01× | 15.9 | 0 |+| 3 | **5.861111 s** | 6.243661 s | 5.794541 s | 1.08× | 11.1 | 0 |+| 4 (at the 12 s ceiling) | **5.835543 s** | 5.945431 s | 5.737718 s | 1.04× | 5.3 | 0 |+| 5 (at the 16 s ceiling) | **5.941550 s** | 6.181430 s | 5.775220 s | 1.07× | 241.5 | 0 |+| **in the whole target** | **7.712017 s** | 8.169167 s | 6.409896 s | 1.27× | ~96 | 0 |++**Run on its own the band is 5.544–5.942 s of median, every sample between+5.541 s and 6.244 s.** Each run took 38.8–41.1 s of test time; the release build+is paid once.++**The arm barely notices the host, which is the most useful thing these five+runs say.** The load average across them ran 79.5 → 15.9 → 11.1 → 5.3 → 241.5 —+a factor of 45 between the quietest and the loudest — and the medians moved+7.2%, with the *loudest* run 3% below the second-loudest. A pass that is one+long series of chunked SwiftData saves against one store file is bounded by that+store, not by how many other processes want a core. The earlier busy-host+reading taken while this feature was being built (median 5.43–5.65 s over the+same 7,009 rows) sits just below the band rather than above it.++**What the arm does notice is the rest of the target.** Measured inside+`make test-performance-m4`, after twenty minutes of other M4 suites, it reads+**7.712 s** — 1.30× the top of the solo band, with a 1.27× internal spread+against the solo runs' 1.01–1.08×. That is the page cache, not the load+average, and it is the number the ceiling is drawn from, because the target is+where this arm will actually be read.++**One run is still not a baseline** (`CLAUDE.md`, and Decision 10 of+`library-integrity-tolerance`): `percentile(of:)` returns the second-slowest+sample, and this arm takes three samples rather than twenty, so its p95 *is* its+max. Six readings are recorded for that reason.++## The per-phase split++From the pass's own `notice` lines, `subsystem: me.nore.ig.Asterism+category: EmptyLibrary`:++```+/usr/bin/log show --last 15m --style compact \+  --predicate 'category == "EmptyLibrary"'+```++Nine passes (runs 1–3) came back in one window, and the logged+`Emptying started` → `Emptying deleted` span matches each measured sample to+within a millisecond — so the split below accounts for the whole recorded+number, not a part of it.++| Phase | Rows | Mean over 9 passes | Share | Per row |+|---|---|---|---|---|+| 1 entries (`Site.entries` detached) | 5,000 | **4.129 s** | **71.6%** | 0.83 ms |+| 2 memberships | 1,000 | **1.369 s** | **23.8%** | 1.37 ms |+| 3 characters | 0 | 0.000 s | 0.0% | — |+| 4 work attachments | 0 | 0.000 s | 0.0% | — |+| 5 works | 1,000 | **0.258 s** | **4.5%** | 0.26 ms |+| 6 directories | 6 | 0.002 s | 0.0% | 0.33 ms |+| 7 sites and rules | 3 | 0.003 s | 0.0% | 1.0 ms |+| sweep 2 (all phases) + validate + cleanup | 0 | 0.002 s | 0.0% | — |+| **total** | **7,009** | **5.764 s** | 100% | 0.82 ms |++The second sweep found nothing in every one of the twelve passes, which is Q31's+loop terminating on the first re-check, and the whole tail after sweep 1 — the+second sweep, `validateStore`, the diagnosis publication, the spool and marker+cleanup and the sidecar clear — costs **2 ms**.++## What the design's first risk turned out to be++The risk was: *"with only `Site.entries` detached first, the `.nullify`+maintenance on `Work.entries`, the two membership inverses and `Work.characters`+may still dominate the pass over the M4 fixture."*++**It does not, and the deletion order is why.** Those inverses all hang off+`Work`, and `Work` is phase 5 — by which time phase 1 has deleted every Entry+and phase 2 every membership, so each array SwiftData would have to maintain is+already empty. Phase 5 is **4.5% of the pass, 0.26 ms per Work — the cheapest+per-row figure in the list**, against the 8.0 ms per row that `Site.entries`+maintenance cost the settling pass before Decision 32 detached it. Children+before parents (Q11) is not only about leaving a valid library at each commit+boundary; it is also what makes the undetached inverses free.++**So no production code changed, and `SiteInverseReachTests` earns no third+sanctioned entry.** Q32 stands as written: only `Site.entries` is detached.++**The one phase worth a note for the next run** is phase 2. Memberships cost+**1.37 ms per row against the works phase's 0.26 ms**, over the same 1,000 rows+in the same two chunks — 5.3× for a table that declares no relationship at all+and therefore has no inverse to detach. **Why is not known.** An earlier+version of this note blamed a validating save strategy re-deriving the tuple set+at each commit; the pre-push review checked, and no such strategy exists outside+the tests. The arm runs on `ModelContextSaveStrategy`, whose `save` is+`context.save()` and nothing else, and the pass gates on no diagnosis. The+review's hypothesis, unmeasured: phase 2's saves are the first after phase 1+committed 5,000 deletions, so they absorb the write-ahead log's checkpoint tail,+which would also explain why the works phase, later in the same context over the+same row count, is 5.3× cheaper. Logging per chunk instead of per phase, or+swapping phases 2 and 5 in a throwaway run, would settle it. It is 24% of a pass+that meets its ceiling with half to spare, so nothing is owed on it.++## The ceiling++The provisional 60 s in `M4EmptyLibraryPerformanceTests.swift` is replaced by+**16 s**.++**The ratio is ~2× the recorded median, which is what the other M4 arms use** —+`dedupeLinksCeiling` is 25 ms over a ~11 ms median, `dedupeCreditsCeiling` is+130 ms over 62.6 ms, `convergeCreatorsCeiling` is 20 ms over ~10 ms, each with+the same reasoning written beside it: generous enough that host variance cannot+fire it, tight enough that the phase having become something else does.++**Which median, though — and that is the whole decision.** Drawn at 2× the solo+band the ceiling is 12 s, and that is what it was set to first. The+whole-target run then measured the same arm at **7.712 s**, which clears 12 s by+only 1.47×. `CLAUDE.md` records a contended run of this target *doubling* an arm+that never opens a store, so a 1.47× margin is a false-fire waiting to happen on+a machine this project has already seen.++So the ceiling is drawn at ~2× the **in-target** median instead:++| Against | Value | 16 s is |+|---|---|---|+| in-target median | 7.712017 s | **2.07×** |+| slowest sample seen anywhere | 8.169167 s | **1.96×** |+| slowest solo median | 5.941550 s | 2.69× |+| fastest solo median | 5.544201 s | 2.89× |++Run 5 confirms it green after the change: 5.941550 s, `EXIT=0` — and it was+taken at load **241**, the loudest moment of the session, which is the clearest+statement available that this arm's solo number is the code rather than the+machine.++**Was the host quiet enough to draw a ceiling from?** For the solo runs it did+not matter, which the five runs demonstrate rather than assume. Run 4 was taken+at load 5.3 — inside the 2.5–5.5 that+`specs/update-schedule/verification-run.md` §5.4 records as a quiet host — and+agreed with the loud ones to within 2%. **What is not quiet is the whole-target+reading**, taken at load ~96 with another agent's UI suite running, and that is+the one the ceiling leans on. A quiet in-target reading is therefore **owed**;+if it lands near the solo band the ceiling could be tightened to 12 s, and this+section is the record of why it is not there today.++## The whole target still runs, with the new suite in it++`make test-performance-m4`, once, started at load 187 and finishing at load ~96+— the other agent's UI suite was running throughout.++```+✘ Test run with 46 tests in 8 suites failed after 1272.908 seconds+  with 10 issues (including 9 known issues).+EXIT=2+```++**Suite count 8, test count 46, 1,272.9 s (21 m 13 s) plus the release build** —+the suite count and test count `CLAUDE.md` now carries. `M4 empty-library scale+budgets` **passed with no known issues**, in 50.2 s, so the new suite costs the+target about 50 seconds.++**The nine known issues are the expected set**, in the shape `CLAUDE.md`+describes:++| # | Arm | Where |+|---|---|---|+| 1 | credit dedupe (Req 11.6) | `M4CreatorScalePerformanceTests.swift:344` |+| 2 | backup export peak (Req 8.6) | `M4DuplicateScalePerformanceTests.swift:396` |+| 3 | full-tier no-op reconcile (Req 1.7) | `M4ScalePerformanceTests.swift:475` |+| 4–6 | capture rule application ×3 (Req 5.4) | `M4ToleratedScalePerformanceTests.swift:273` |+| 7–9 | diagnosis re-derivation ×3 (Req 5.5) | `M4ToleratedScalePerformanceTests.swift:469, 494, 512` |++Nine rather than ten because **`dedupe-links-noop` finally fits**: median+**0.009813 s** against its 10 ms budget, 1.9% *under*. `CLAUDE.md` has been+predicting this ("0.3% over … the closest it has come to fitting") and warning+it would turn a quiet host into a second way to be red; it did not — the series+suite passed — so that known issue tolerates not occurring. `creator-converge-noop`+likewise came in at 0.006616 s and did not record, which is what+`isIntermittent` is for.++### The one real failure is an untouched arm on a contended host++```+✘ Test "Diagnosis re-derivation ≤ 250 ms with duplicate Site rows (Req 5.5)"+  recorded an issue at M4ToleratedScalePerformanceTests.swift:515:28:+  Expectation failed: measured.median <= diagnosisRefreshCeiling+```++That arm's budget breach is known issue #9; what failed is the **regression+ceiling asserted outside the block**. The measurement says why:++```+ASTERISM-PERF diagnosis-refresh-duplicateSiteRows+  median=0.484867s p95=1.084915s min=0.299662s max=2.105844s spread=7.03x n=20+```++**A 7.03× internal spread**, min 0.300 s against max 2.106 s. Its two siblings+in the same suite spread 1.82× and 1.31× on the same run, against the+1.01–1.13× that every settled arm in this run managed. This is the case+`CLAUDE.md` already names: *"A loaded run of those same 32 tests took 1,380 s+and breached a regression ceiling on an untouched arm; that is host contention,+not a band."*++**Nothing was re-based.** The arm belongs to `library-integrity-tolerance`, this+branch touches nothing it reads, and one contended run is not evidence to move+another feature's ceiling. It is reported here and left alone.++---++# Task 19: the suites and the builds++| Target | Exit | Result |+|---|---|---|+| `make test-core` | 2 | **Zero known issues.** Every `empty-library` suite green. Six suites failed, all in two families older than this branch — see below |+| `make test-quick SKIP_MAC=1` | **0** | **1,695 cases green, 0 failures, 0 compiler warnings.** `SKIP_MAC=1` because `build-mac` cannot sign under the lock |+| `make build-mac` | 2 | **Fails at `CodeSign`** of `AsterismShareExtensionMac.debug.dylib`, before the app target compiles. No macOS compile evidence. **Owed** |+| `make build-mac-release` | — | Not attempted: the same signing step, same lock. **Owed** |+| `make build-release` | **0** | `Personal` on the iOS simulator, build only. **Build Succeeded**, and `EmptyLibrary` appears nowhere in the log — Req 1.1's gating evidence in the Mac build's place |+| `make test-ui` | 2 | **186 executed, 2 skipped, 4 failures**, 5,938 s. `EmptyLibrarySettingsUITests` **3/3 green**. All four failures older than this branch — see below |++## `make test-core`: zero known issues, and six suites that are not this branch's++`CLAUDE.md` expects **zero** known issues with no schema window open, and the+run reported zero. All 16 cases of the new `Emptying the library` suite passed,+as did `Model and schema contracts` (the phase-list coverage test) and+`The Site inverses stay unreached` (the sanctioned-traversal guard). The+132 recorded issues divide exactly into two known families:++| Family | Suites | Issues | Evidence it is not this branch's |+|---|---|---|---|+| **Locked-screen EPERM** | `Pending-capture spool` (46), `Preserve-first share flow` (20), `Pending-capture drain` (55) | **121** | Re-run filtered and alone: the same 46, 20 and 55 issues, same signature — `Operation not permitted` / `you don't have permission to view it` on the spool's own record files. **121 is the count `docs/agent-notes/testing.md` records for an overnight locked screen**, reliably, and this branch touches no file in any of the three |+| **`Z_METADATA` / staged migration** | `Certification paths` (8), `A 14.0.0-recorded store under the V15 plan` (2), `Store-opener parity` (1) | **11** | Re-run filtered and alone: 8 issues over 10 tests in 3 suites, every one `.unreadable("Z_METADATA unreadable: database is locked")` or `AsterismV3.sqlite has no Z_METADATA row`. Reproduced at the base commit `8138509d` earlier in this feature's work. None of the three suites' files is on this branch |++Both re-runs are in the session logs; neither family has an empty-library symbol+anywhere in it, and the branch's own additions+(`LibraryRepository+EmptyLibrary.swift`, `PendingCaptureSpool.swift`'s+`inventory`/`discard`, `LibraryConfiguration.emptySidecarURL`) are covered green+by the `Emptying the library` suite — including Req 3.6 and 3.7's spool and+owed-marker arms, which seed and discard a spool in their own temporary+directory and are unaffected by the lock.++**No compiler warnings from this branch.** The package built from scratch (306+steps). Every `warning:` in the log is in a file this branch does not touch:+four in `RepositoryReShareTests.swift` and four `FoundationModels` deprecations+in `AsterismIntelligence` and its tests.++**No code fix was made, because no failure was this branch's.**++## `make test-quick`++`EXIT=0`, 1,695 cases, zero failures, zero warnings, on+`iPhone 17 Pro,OS=26.5` (the Makefile's default `iPhone 17 Pro` does not+resolve on this host). The feature's app-side suites — `Empty library model`,+`AppLibraryModel empty library`, `Backup export stage`, `Document export+outcome` — all passed.++It was run with **`SKIP_MAC=1`**, which the Makefile's own banner calls out: the+pre-commit bar with that flag is `make test-core` plus this run, and **one clean+`make build-mac` is owed before the branch is pushed.**++## The two Mac builds are owed, and the lock is why++`make build-mac` fails here:++```+The following build commands failed:+	CodeSign …/AsterismShareExtensionMac.appex/Contents/MacOS/AsterismShareExtensionMac.debug.dylib+	(in target 'AsterismShareExtensionMac' from project 'Asterism')+```++with `errSecInternalComponent` — the keychain cannot be reached to sign while+the console is locked. It happens **before** the `Asterism` app target compiles+(the log reaches `Generate Asset Symbols` and no app-target compile), so the run+proves nothing about whether this branch's app-side changes build on macOS.+`make build-mac-release` is the same command at the `Personal` configuration and+fails at the same step.++**Both are owed, and unlocking the console is all that is needed.** They are the+reason task 19 is left unchecked in this sitting (it is settled in the second sitting, below).++## `make build-release` stands in for what the Mac build would have shown++`make build-release` — the `Personal` configuration on the iOS simulator, build+only, no install and no launch — **succeeded**. It is not a macOS compile, so it+does not discharge what `build-mac-release` is for, but it is the evidence for+the half of Req 1.1 that matters most: **the string `EmptyLibrary` does not+appear anywhere in the build log.** `EmptyLibraryModel.swift` and+`LibraryRepository+EmptyLibrary.swift` are not compiled into `Personal`, and the+app target builds without them — which is what "a `Personal` build SHALL contain+neither the row nor the code behind it" asks for.++## `make test-ui`: 186 executed, 4 failures, all four older than this branch++On `iPhone 17 Pro,OS=26.5`, 5,938 s (99 minutes), the two iPad-only suites+skipped by the target as always.++**This feature's own journeys are green, first time:**++```+Test Suite 'EmptyLibrarySettingsUITests'+    ✔ testARefusedExportReportsTheRefusalAndRoutesToCheckLibrary   (23.5 s)+    ✔ testDismissingTheShareSheetDeletesNothing                    (30.2 s)+    ✔ testTheDialogNamesTheCountsAndCancellingChangesNothing       (27.8 s)+Executed 3 tests, with 0 failures+```++That is all three journeys the design's testing strategy names: the cancel arm+of Req 1.3, the share-cancelled arm of Req 2.3, and the torn-fixture refusal+with its Check Library route.++The four failures:++| Case | Message | Classification |+|---|---|---|+| `WorkDetailStatusUITests.testTheStoredScheduleShowsOnTheMetaLineAndCommitsFromTheToggles` | `XCTAssertGreaterThan failed: ("12.0") is not greater than ("22.0")` | **Pre-existing.** Recorded **verbatim** in `specs/catch-up-mode/verification-run.md`, where it was reproduced at `215c843` — the `update-schedule` merge on `main` — in a worktree with no branch code in it |+| `WorkDetailStatusUITests.testCancellingTheEditorDiscardsTheToggledReleaseDays` | the same assertion, plus `Req 2.1: the item draws its abbreviations beside the glyph, not the glyph alone` | **Pre-existing.** Same table, same verbatim message including the trailing sentence |+| `AccessibilityJourneyUITests.testTheEntryTitleCardsWorkNameOpensTheWorkAtLargestDynamicType` | `XCTAssertTrue failed - Recent lists a taught chapter` | **Pre-existing.** Same table, verbatim |+| `CharacterExtractionUITests.testKeepingAPlaceWritesItIntoThePlacesSection` | `failed - The place can be kept` | **Load flake.** Re-run alone: **green in 80.5 s, `EXIT=0`**. In the full run its suite took 913 s for 15 tests while another agent's UI suite had the machine; every sibling passed, including `testAKeptPlaceIsNamedOnTheEntryItCites` over the same fixture |++Three of the four are the same three `catch-up-mode` recorded on 2026-09-17 and+could not explain either — that spec calls them "unexplained, not attributed"+and says they want a bugfix spec of their own. **Nothing here changes that+verdict, and nothing here is evidence against this branch:** the branch touches+no file under `WorkDetail`, `Accessibility` or `CharacterExtraction`, and its+own suite is green. The fourth of `catch-up-mode`'s four is+`WideLayoutUITests.testTheWorkNameInTheDetailColumnOpensTheWorkOnTheWorksPane`,+which `make test-ui` skips by name, so it could not appear here.++**No code fix was made for any of them**, per the rule that an unrelated+pre-existing failure is reported rather than "fixed".++---++# Task 19, second sitting: the keychain, not the screen++2026-09-19, 10:52–11:02, host load 122 falling to 9 with other sessions' test+runs on the machine. The owner's correction: the CodeSign failure was a+**locked keychain**, not the locked screen. `CGSSessionScreenLockedTime` agrees:+the screen had been locked since about 01:05 and stayed locked through every+command below, all of which signed.++| Command | Result |+|---|---|+| `make build-mac` | **EXIT=0, Build Succeeded, 0 warnings.** The feature's files compile for macOS, and `AsterismShareExtensionMac.appex` is embedded |+| `make build-mac-release` | **EXIT=0, Build Succeeded, 0 warnings** at `Personal`. `strings` over the `Personal` Mac binary finds **0** occurrences of `EmptyLibrary`; the `Development` one holds 9. Req 1.1 and Q22 hold on macOS |+| `make test-quick SIMULATOR='iPhone 17 Pro,OS=26.5'`, without `SKIP_MAC` | **EXIT=0, Test Succeeded, 120 suites passed, 0 warnings** |+| `make test-core` | **EXIT=2**, zero known issues. `Pending-capture drain`, `Pending-capture spool` and `Preserve-first share flow` **pass**, so that family was the keychain's too. 13 issues remain in five store-metadata suites, below |++**The store-metadata family is not this branch's and has a fix in review.**+`Certification paths` (7), `Store-opener parity` (3), `Marker generation 15`,+`Advisory recorded store version` and `A 14.0.0-recorded store under the V15+plan` (1 each) fail with `Z_METADATA unreadable: database is locked` or+`AsterismV3.sqlite has no Z_METADATA row`. `Certification paths` alone failed+twice more, with 6 issues at load 10 and 4 at load 9, so it is neither the lock+nor contention. Each test opens a repository, lets it go and reads `Z_METADATA`+with a raw SQLite connection at once; the released container closes its store+late, and the reader sets no busy timeout, which is the race+`docs/agent-notes/testing.md` describes for `StoreSettling`. The family+reproduces at the base commit `8138509d`, touches no file of this feature, and+the owner confirmed on 2026-09-19 that a bugfix for these tests is in review.+Task 19 is ticked on that basis: every suite of this feature and every suite+over a file this branch changed is green.++---++# After the rebase onto `main`++2026-09-19, 12:00–14:30. The branch was rebased onto `origin/main` at+`8430226f`, which brought #76 (Xcode 27 project settings), #77 (the default+simulator becomes `iPhone 18 Pro`) and #78 (raw store readers wait for a closing+connection). 23 commits replayed with no conflict. Another session ran+`make test-ui` on `iPhone 18 Pro` throughout.++| Command | Result |+|---|---|+| `make test-core` | **EXIT=0. 275 suites passed, 0 failed, 0 known issues.** The store-metadata family is gone with #78 underneath |+| `make test-quick SIMULATOR='iPhone 17 Pro,OS=26.5'` | **EXIT=0. Build Succeeded on macOS, 120 suites passed, 0 failed, 0 warnings** |+| `make build-mac-release` | **EXIT=0, 0 warnings.** The `Personal` Mac binary still holds 0 `EmptyLibrary` strings |+| `make test-ui SIMULATOR='iPhone 17 Pro,OS=26.5'` | **EXIT=2. 178 passed, 6 failed.** `EmptyLibrarySettingsUITests` 3/3. Classified below |++**`make test-quick` on the default simulator is not safe while another session+tests on it.** The first run, on `iPhone 18 Pro`, exited 65 naming five tests+with no recorded issue. The crash report shows the app dying in dyld at launch:+`Symbol not found: LibraryRepository.emptyLibrary()`, referenced from this+branch's `Asterism.debug.dylib`, expected in an `AsterismCore.framework` that+lacks it. Both sessions install `me.nore.ig.Asterism.dev` on the one device, so+one session's app ran against the other's framework. It is not a test failure+and the same run is green on a simulator of its own.++**The six UI failures.** Three are the set `specs/catch-up-mode/verification-run.md`+reproduced on `main` and the first sitting met too: the `WorkDetailStatusUITests`+pair (`("12.0") is not greater than ("22.0")`) and+`AccessibilityJourneyUITests.testTheEntryTitleCardsWorkNameOpensTheWorkAtLargestDynamicType`.+The other three passed when run alone straight afterwards, the second after one+retry of the runner's `Busy` preflight refusal:+`WorkCoverUITests.testTheCoverViewerIsUnreachableWhenTheBytesAreAbsent`,+`WorksCreatorOptionsUITests.testTheCreatorFilterNarrowsTheListAndExplainsAnEmptyOne`+and `AccessibilityJourneyUITests.testThePlacesSectionAndReviewKindControlAtLargestDynamicType`.+None touches a file of this feature.++---++# After the second rebase, onto #79++2026-09-19, later the same day. `main` gained #79 (share sheet character chips,+`1e30f171`), which touches Core and the share extension. The rebase conflicted+four times, all in `specs/OVERVIEW.md` and all the same shape: #79 appended its+table row and its section exactly where this branch appends and then edits its+own. Both entries are kept, Empty Library first by date. The tree differs from+the pre-rebase tip by #79's twenty files and nothing else.++| Command | Result |+|---|---|+| `make test-core` | **EXIT=0. 277 suites passed, 0 failed, 0 known issues** |+| `make test-quick SIMULATOR='iPhone 17 Pro,OS=26.5'` | **EXIT=0. Build Succeeded on macOS, 121 suites passed, 0 failed, 0 warnings** |+| `make test-only TEST=AsterismUITests/EmptyLibrarySettingsUITests` | **EXIT=0, 3/3** |++The full `make test-ui` and `make build-mac-release` were not repeated; the+section above is their last run.++---++# After the device run and the pre-push review++2026-09-19, evening. The owner ran the phone arm on a `Development` install: the+archive saved through Save to Files and nothing was emptied. `documentExporter`+delivered the outcome at the sheet's dismissal and dropped the report that+followed it, which on Save to Files is the real one (`decision_log.md`+Decision 6, commit `fd3857e7`). With the fix installed the owner confirmed the+hand-off empties the library. That is the only verification the completed arm+has or can have; no test can complete an activity.++The pre-push review then ran four reviewers over the whole branch. No blocker.+Its code fixes (`6ad02a13`, `e05ffd79`, Q45–Q47): a run that cannot take the+lock clears its sidecar, a run refuses while another bulk operation holds the+flag, a chunk that saves without removing its rows is an interruption rather+than a loop, the spool listing reuses the spool's own helpers, and the sharing+seam logs under `DocumentExport`.++| Command, at `e05ffd79` | Result |+|---|---|+| `make test-core` | **EXIT=0, zero known issues**; `EmptyLibraryTests` 19/19 |+| `make test-quick SIMULATOR='iPhone 17 Pro,OS=26.5'` | **EXIT=0**, macOS build included, no new warnings |+| `make test-only TEST=AsterismUITests/EmptyLibrarySettingsUITests` | **EXIT=0, 3/3** |+| `make build-release` | **EXIT=0** |+| `swift test --package-path Packages/AsterismCore --no-parallel --xunit-output …` (the review page's run) | first run **1 issue in 2,802 tests**, second run **2,802 tests in 264 suites passed** |++The one issue was `MarkerGenerationFifteenTests`' "A failed open leaves the+marker at "14", and the next open completes it", which caught+`SwiftDataError.loadIssueModelContainer` on its second open. The suite then+passed three times alone and the whole run passed on the repeat. This branch+touches nothing on the open path; it reads as the second open racing the first+container's late close, the family #78 closed for the raw readers. Seen once,+so recorded, not chased.++**Owed after this sitting:** runbook steps 3 to 6 on the devices (step 2's+completed hand-off is confirmed), and the quiet-host `make test-performance-m4`+below.++---++# What was not run, and what is owed++**The four items below were owed after the first sitting and were settled in the second sitting and in the rebase runs, above: the store-metadata fix landed on `main` as #78 and `make test-core` has been clean since.**++- `make build-mac` — the Req 9.1 macOS compile, and the assertion that+  `AsterismShareExtensionMac.appex` is embedded. Done in the second sitting.+- `make build-mac-release` — the same at `Personal`, which is what task 19 asks+  for as proof that the gated files are absent on macOS. `make build-release`+  above shows it for the iOS `Personal` build, which is not the same target.+- One `make test-quick` without `SKIP_MAC=1`, once those two can sign.++**Owed, needing a quiet machine rather than an unlocked one:**++- A quiet-host `make test-performance-m4`. This one was taken at load 96–187+  and cost an untouched arm its regression ceiling. It would also settle whether+  the `empty-library-m4` ceiling can come down from 16 s to 12 s, and it is the+  same quiet-host run of the covered fixture that `CLAUDE.md` has been owed+  since `work-thumbnails`.++**Not run, and not this task's:**++- Everything in `runbook.md`: the completed-share arm on the phone and on the+  Mac, the two-device arm, the second run that clears the residue, and the store+  size on disk before and after. All are `Development` device runs and the+  owner's to make.+- `make test-ui-ipad`. Task 19 names `make test-ui`; the two iPad suites are+  skipped by that target by design and this feature adds no wide-layout surface.+

Things to double-check

Decision 4 weakens an approved requirement.

Req 5.1 lost its "no pending changes" clause. Confirm it, or T-2334 has to land first.

The iOS delivery wiring has no automated net.

The owner confirmed the completed Save to Files hand-off on a phone after the fix. Runbook steps 3 to 6 are still owed, step 6 being the stale-completion check.

One intermittent failure in the review's own run.

MarkerGenerationFifteenTests failed once on its second open (loadIssueModelContainer), passed three times alone and on the full repeat. Not a file this branch touches.

Two sessions on the default simulator corrupt each other.

Since #77 every session installs the Development app on iPhone 18 Pro. Run concurrent sessions with SIMULATOR='iPhone 17 Pro,OS=26.5'.